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). What is bundled, named with its copyright, includes 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) - the code-system identities, the OID ↔ canonical-URI ↔
mnemonic pairings, which identify the systems rather than listing any system's codes, and RxNorm's
relationship and term-type names (RELA, RELA_INVERSE, TERM_TYPES), published by the U.S.
National Library of Medicine. Individual SNOMED CT, ICD-10-CM and RxNorm identifiers also appear in
the API documentation examples. The full list is in the package's LICENSE.
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 no RxNorm release;
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 bundles RxNorm's relationship and term-type names but no RxNorm release. 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. Diagnostic and fatal messages are value-free: nothing you configured and
nothing your release or resource contained reaches a message or an err.stack, and the locus
beside one is a line number, an index path into your own resource, or a fixed token. The objects are the opposite
by design - a typed unknown/unmapped outcome names what you asked about, and a code
in patient context can be PHI, so log the code and the locus rather than the whole result.
Immutability
A loaded ConceptMap, a Coding and a TranslateResult are deep-frozen. So is the bundled UCUM
unit table, atom by atom: one table is shared by every caller and by the engine's own reducer, so a
definition rewritten in place would change what a unit means for the whole process.
An index a release you load exposes as a ReadonlyMap - the concepts of a code system, a GEM's or
a complex map's entries, a drug graph's concepts, edges and NDCs - is a read-only view of a map
rather than the map itself. Reads are exactly a Map's (get, has, size, keys, values,
entries, forEach, iteration, and new Map(view)), and adding, deleting or clearing is refused
whichever way it is attempted. Why it matters is the never-fabricate invariant: without it, whatever
holds a loaded release could empty a medication's ingredient edges, delete a diagnosis mapping, or add
a target the steward's file never authored, and the engine would answer from it while count went on
reporting what was loaded.
Three consequences to know before you write against one. readonly in the type is a compile-time
claim and this is the run-time one, so the refusal happens whether or not you type-check. A view is
not a Map instance: instanceof Map is false, it prints as a plain object, Object.keys
lists its own methods, and JSON.stringify renders it as {"size":2} where a Map rendered {} -
so a JSON round-trip of a loaded model carries a size over no entries, having never carried the
entries either. And a model carrying a view cannot be structured-cloned: structuredClone and
worker.postMessage raise a DataCloneError, v8.serialize a plain Error, both naming an
uncloneable function. Copy out what the other side needs instead (new Map(cs.concepts) clones
fine). That refusal is deliberate rather than incidental: the alternative made the clone succeed and
hand back a model whose indexes were empty while its counts still reported the loaded figures. A
loaded ConceptMap, ValueSet or unit table carries no view and clones as it always did.
The bundled UCUM table is the exception, deliberately and as before: atomByCode and
prefixByCode are real Maps whose set, delete and clear are replaced by a refusal, so an
entry forced through Map.prototype still lands. What that reaches is bounded - parseUcum resolves
an atom by scanning the frozen atoms array, never through a lookup map, so a forced entry changes no
validation, no reduction and no ucumEqual answer; only your own get is lied to.
What parseUcum and reduce hand back is deliberately not frozen, because it is yours rather
than the engine's: both are built per call, and mutating one changes nothing for anyone else.