Core Concepts
@cosyte/terminology is a terminology engine, not a wire-format parser. It mirrors the FHIR
Terminology Module - the industry's own architecture for keeping terminology a swappable
service separate from the data that uses it - so its operations ($translate, $lookup,
$validate-code, ValueSet $expand / binding, UCUM unit validation, and the published crosswalk
resolvers) speak the FHIR shape every downstream tool already understands.
Bring-your-own data, engine-only
The engine ships no code-system release. You feed it standard FHIR resources - a ConceptMap, and
later a CodeSystem/ValueSet - and it answers questions over them. SNOMED CT, CPT, LOINC, the
UMLS/RxNorm release, and VSAC value sets are strictly consumer-supplied (a licensing wall, not a
feature gap). Bundled, and named with their copyright, are the UCUM unit table (copyright ©1999–2024
Regenstrief Institute, Inc., verbatim under https://ucum.org/license; notice shipped at
vendor/ucum/NOTICE.md), the SNOMED CT concepts the crosswalk resolver names - the map-category
concepts and the two gender findings (SNOMED CT is copyright © International Health Terminology
Standards Development Organisation) - and the code-system identities, the OID ↔ canonical-URI ↔
mnemonic pairings, which identify the systems rather than listing any system's codes.
The never-fabricate invariant
The single rule the whole library is built around: the engine never invents a code, display, unit, or map target.
- A
$translateover an unmapped source returns a typedunmappedresult - surfaced, carrying any declaredgroup.unmappedfallback mode - never a guessed target. - Every target
translatereturns is drawn verbatim from the supplied map. - An unrecognized code system resolves to a typed
unknown, never a guessed URI. - A ValueSet
$expandwhose parts cannot all be computed (a missing code system, an unimplemented filter, a truncated server expansion) returnscomplete: falsewith a typedTERM_VALUESET_CANNOT_EXPAND/TERM_VALUESET_EXPANSION_TRUNCATEDdiagnostic - thecontainsset is a lower bound, never a silently-empty membership. - ValueSet binding (
validateCodeInValueSet) returns a decidedresultonly when it can prove membership; otherwise a typedundetermined- never a fabricated "not a member", because a false negative on a binding is a clinical error.
The never-invert invariant
Real steward maps (SNOMED→ICD-10-CM, the GEMs) are approximate, 1:many, and non-invertible.
translate reads a map in its authored direction only - it matches a source coding against the
map's source-side codes and never against targets - so a directional map cannot be run backwards.
Reverse translation requires an explicit inverse map; it is never synthesized. The CMS GEMs make this
explicit: the forward (9→10) and backward (10→9) files are separate artifacts, and invertGem
throws TERM_MAP_NOT_INVERTIBLE rather than fabricate a transpose.
The crosswalk resolvers
The published reference maps are the library's highest-risk surface, and the stewards say so in as many words - CMS: "GEMs are not crosswalks. They are reference mappings"; NLM: the SNOMED→ICD-10-CM map is "semi-automated." The resolvers honour that.
- ICD-9↔ICD-10 GEMs (
loadGems/applyGem) - CMS public-domain. Each entry carries the steward's 5-position flags (approximate | no-map | combination | scenario | choice-list), surfaced verbatim. A 1:many source returns the whole candidate set; a combination source surfaces its scenario→choice-list structure so a caller can build valid clusters (one target per choice list); aNoDxNo-Map source is the typedTERM_CROSSWALK_NO_MAP, distinct from a source simply absent from the file (TERM_CROSSWALK_UNMAPPED). - SNOMED CT → ICD-10-CM complex map (
loadComplexMap/applyComplexMap) - BYO (SNOMED is licensed; the engine bundles no SNOMED CT refset or release - it bundles the rule machinery and the individual concepts that machinery names, the map categories and the two gender findings). A source's map groups are an AND (a manifestation code and an etiology code → two groups); within a group, priorities are an if-then-else chain ofIFArules evaluated against caller-suppliedPatientContext(age band, gender). A group whose decision needs context the caller did not supply is the typedTERM_CROSSWALK_CONTEXT_REQUIRED- the candidate rules and Map Advice ride through, and the engine refuses to pick a branch it lacks the data for. Steward Map Categories (447638001cannot-classify,447639009context-dependent,447640006ambiguous) are carried verbatim.
Both are directional and never inverted, and both extend the same never-fabricate rule: a crosswalk never invents a target, and "No-Map" is a first-class typed outcome, never an empty success.
The RxNorm drug graph
RxNorm's drug graph - ingredient (IN) → clinical-drug component (SCDC) → semantic clinical drug
(SCD) → semantic branded drug (SBD), with brand (BN) and dose-form (DF) cross-links - is
navigated over a bring-your-own RxNorm RRF release (loadRxNormGraph reads RXNCONSO concepts,
RXNREL relationships, and RXNSAT NDC attributes). The engine bundles zero RxNorm content;
the graph is entirely the caller's release, and a resolution is release-scoped.
- Direction is a documented trap, grounded firsthand. RxNorm's
RXNRELstores each relationship as "the relationship which the second concept (RXCUI2) HAS TO the first (RXCUI1)" (NLM RxNorm Technical Documentation §12.7; UMLS Reference Manual). So a row is readRXCUI2 ⟶RELA⟶ RXCUI1, and the loader normalizes every edge tosubject = RXCUI2,object = RXCUI1. Ahas_ingredientrow therefore puts the concept that has the ingredient inRXCUI2and what it has inRXCUI1. Getting this backwards is a wrong-medication bug, so it is pinned by fixtures. - Direction is not topology. Knowing how to read a row does not tell you which rows exist, and
RxNorm's ingredient topology is not the obvious one.
has_ingredientis authored from the clinical side (SCDC/SCDF/SCDG) to the ingredientIN, and from the branded side (SBD/SBDC/SBDF/SBDG) to the brand nameBNrather than to the active ingredient. There is noSCD ⟶ INedge in any release, soingredientsOfon a clinical drug answers with an honest empty set, and an active ingredient is reached by walking to the clinical component and taking its edge (SCD ⟶ SCDC ⟶ IN; from anSBD,consists_ofreturns theSBDCas well as theSCDC, and it is theSCDCthat leads to theIN). LikewiseIN ingredient_ofreaches components and dose forms, never the clinical drug. The precise forms (SCDFP/SBDFP/SCDGP) carry no ingredient edge either: anSCDFPreaches its basis-of-strength substance(s) byhas_boss, a one-to-many relation whose targets can be aPINor a plainINand whichingredientsOfdoes not follow, and the other two author none at all. Read thettyof what a traversal returns rather than assuming it from the one you queried. - A concept is typed by its defining atom, never by file position. An
RXCUIcarries one defining atom (its normalized name:IN,SCD,SCDF,BN, and so on) plus any number of synonym atoms, whoseTTY(PSN,SY,TMSY) the NLM defines as a "synonym of another TTY": it types a name, not a concept. RxNorm documents no ordering between anRXCUI's atoms, so the loader takes the defining one wherever it sits and never falls back to a synonym. AnRXCUIthat no defining atom could type is left out of the graph and surfaced asTERM_RXNORM_UNTYPED_CONCEPT. Refusing is the safer direction: an absent concept is already a first-class typed answer (TERM_RXNORM_UNKNOWN_RXCUI), while a concept whosettyreadsTMSYis a fabricated claim about what the drug is, and a caller cannot tell it from a real one. - Authored edges only - never inverted. RxNorm ships both directions of an asymmetric
relationship as separate rows, so
genericForfollows the authoredtradename_ofandbrandsForfollows the authoredhas_tradename; the engine never synthesizes a reverse edge. Convenience resolvers (ingredientsOf,doseFormsOf,consistsOf) and the genericrelatedByRelaall follow authored predicates. - Never fabricate. A queried
RXCUIabsent from the release is a typedTERM_RXNORM_UNKNOWN_RXCUI; a present concept with no such relationship is a found result with an empty target set (an honest "no such edge", distinct from "unknown concept"). An NDC not in the release is a typedTERM_RXNORM_NDC_UNMAPPED, never a guessedRXCUI. - NDC↔RXCUI is many:1 and temporal.
resolveNdccarries the temporal status (active/obsolete/alien/unknown) and the as-of release - an NDC present in the loaded release isactiveas of that release; obsolete/alien statuses come from RxNav NDC-history data (a differential/BYO source, not the base RRF concept files) and are never fabricated. - Coverage is not correctness. Approximate name matching (
approximateMatch) is an opt-in, explicitly labeled path - never the default resolution and never an exact assertion (every candidate carriesapproximate: trueand a derived score). A "no match" is an empty array, never a nearest guess.
Content posture. This release ships the graph mechanism over the real RRF format, grounded firsthand on the RxNorm technical documentation; it does not bundle RxNorm content. The public-domain Current Prescribable Content pack is not bundled either: bundling one requires a genuine, verbatim RxNorm release, and none is fabricated to fill the gap.
Liberal load, conservative assertion
Loading is liberal: loadConceptMap accepts an untrusted unknown and degrades a malformed
resource to a typed fatal (TERM_CONCEPTMAP_MALFORMED) rather than crashing - but it refuses to load
a structurally partial, misleading map. Assertion is conservative: a 1:many mapping returns the
full candidate set (never collapsed to one), and steward advice comments ride through verbatim.
Stable diagnostic + fatal codes
Outcomes carry stable codes - DIAGNOSTIC_CODES (typed, surfaced, non-throwing outcomes like
TERM_TRANSLATE_UNMAPPED) and FATAL_CODES (thrown, like TERM_CONCEPTMAP_MALFORMED). Consumers
branch on these, so a code's name is part of the public contract: renaming or removing one is a
breaking change. Diagnostics are value-free - a code plus, at most, a code + system +
version, never a surrounding patient identifier (a code in patient context can be PHI).
Immutability
Every value the engine returns - a loaded ConceptMap, a Coding, a TranslateResult - is
deep-frozen, so it is safe to share across a pipeline without defensive copying.