Skip to content

About

Terminology engine for Node.js and TypeScript. Runs FHIR $lookup, $validate-code, $translate and $expand over code systems you supply.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark

@cosyte/terminology

A zero-dependency terminology engine for US healthcare code systems: FHIR-shaped, BYO-data, and it never fabricates a code.

@cosyte/terminology is not a wire parser. It mirrors the FHIR Terminology Module ($translate, $lookup, $validate-code, $expand, …), operating over consumer-supplied FHIR resources. It is the sibling engine @cosyte/transform and, later, the parsers' code-system recognition consume. It ships the engine, and no code-system release: SNOMED CT, CPT, LOINC, UMLS/RxNorm, and VSAC value sets are strictly bring-your-own. It is not content-free, though: what it does bundle, including the UCUM unit table, the code-system identities (OID ↔ canonical URI), the SNOMED CT concepts the crosswalk resolver names and RxNorm's relationship and term-type names, is listed with its copyright under What is bundled.

Status: 0.1.0, published on npm. The public API is settled: below 1.0.0 a breaking change ships in a new minor version, never a patch, and the changelog says what broke. The engine is complete, every operation below ships today. The surface:

  • the code-system identity resolver (resolveSystem) and the ConceptMap $translate engine (loadConceptMap / translate);
  • the CodeSystem load layer (RRF / CSV / fixed-width / FHIR JSON) with $lookup and $validate-code;
  • ValueSet binding (compose / $expand / $validate-code), subsumption read from the release's own hierarchy, measured against HL7's own terminology ecosystem test cases (terminology ecosystem conformance);
  • UCUM unit validation and canonicalization (validateUcum / ucumEqual, recognition only, no magnitude conversion), gated on the official UCUM functional tests: the validation and conversion case kinds, against the suite dated 3-Feb 2021, and no other kind (UCUM conformance);
  • the crosswalk resolvers: the CMS ICD-9↔ICD-10 GEMs (loadGems / applyGem, public-domain) and the NLM SNOMED CT → ICD-10-CM complex map (loadComplexMap / applyComplexMap, BYO: no SNOMED CT refset bundled), never inverted;
  • the RxNorm drug graph (loadRxNormGraph + ingredientsOf / genericFor / brandsFor / doseFormsOf / consistsOf / resolveNdc / approximateMatch) over a BYO RxNorm RRF release, edges read in RxNorm's documented direction and never inverted, an absent RXCUI/NDC typed, never fabricated.

Bring your own data. The engine is whole; no code-system release is bundled. It runs over the FHIR resources and standard releases you supply: SNOMED CT, CPT, LOINC, UMLS/RxNorm, the CMS GEM files, the NLM complex-map refset and VSAC value sets are all bring-your-own, under whatever terms their steward sets. The content packs that would change that (RxNorm Prescribable, ICD-10-CM) are not bundled either. For the things that are bundled, and under whose copyright, see What is bundled.

Install

On npm. @cosyte/terminology is published, on the 0.1.x line. The command below installs the real package; the registry, not this page, is the authority on the exact version.

npm install @cosyte/terminology

Runnable programs for the main jobs are in examples/.

Resolve a code system to its canonical URI

import { resolveSystem, isUnknownSystem } from "@cosyte/terminology";

const r = resolveSystem("2.16.840.1.113883.6.1"); // OID, mnemonic, or URI
console.log(isUnknownSystem(r) ? "unknown" : r.url); // http://loinc.org

Translate a code through a ConceptMap

import { loadConceptMap, translate } from "@cosyte/terminology";

const map = loadConceptMap(myFhirConceptMapJson); // a standard FHIR R4 ConceptMap
const result = translate({ system: "http://loinc.org", code: "2160-0" }, map);

if (result.unmapped) {
  // Typed, surfaced outcome: carries any group.unmapped fallback mode. Never a guessed target.
  result.notRelated; // the map's own "not related" assertions, when it made any (see below)
} else {
  for (const m of result.matches) {
    m.target; // a frozen Coding, drawn verbatim from the map
    m.relationship; // "equivalent" | "source-is-narrower-than-target" | … (R5 vocabulary)
    m.equivalence; // the verbatim FHIR R4 equivalence token
    m.comment; // steward map advice, carried through verbatim
  }
}

A map can also say, explicitly, that a source and a target are not related (FHIR R4 equivalence: "disjoint", R5 not-related-to). That is an assertion, not a translation: when every declared target for a source asserts non-relation (disjoint or unmatched), the source reports as not translated (result.unmapped === true), and the rows the map asserted are carried on result.notRelated verbatim, each with its target coding, its R4 equivalence and the author's comment. So a caller branching on the verdict never reads a declared "not related" as a successful translation, and still gets to see what the steward said:

if (result.unmapped) {
  for (const n of result.notRelated ?? []) {
    n.target; // the target the map declared, verbatim: NOT a mapping to use
    n.equivalence; // "disjoint"
    n.comment; // why the steward says these are unrelated, when they said
  }
}

When at least one target does assert a relationship, the source is translated exactly as before and any disjoint row rides along in result.matches, in its declared position.

Look up a code in a code system

import { loadCodeSystem, lookup, validateCode } from "@cosyte/terminology";

// Load a consumer-supplied release: RRF (RxNorm/UMLS), CSV (LOINC), fixed-width
// (ICD-10-CM order file, see ICD10CM_ORDER_FILE_FIELDS), or a FHIR CodeSystem JSON.
const cs = loadCodeSystem({ format: "fhir", resource: myFhirCodeSystemJson });

const hit = lookup(cs, "2160-0"); // $lookup
if (hit.found) {
  hit.display; // the preferred display, verbatim from the release
  hit.status; // deprecated / header-not-billable / obsolete / … carried, never presented clean
} else {
  hit.code; // "TERM_CODE_UNKNOWN": never a fabricated display
}

validateCode(cs, "2160-0").valid; // $validate-code: true iff present; never a guessed true

Validate and compare UCUM units

Recognition and canonicalization only: no magnitude conversion (5 mg/dL → mmol/L needs the analyte's molar mass, a clinical computation this engine refuses).

import { validateUcum, ucumEqual } from "@cosyte/terminology";

const v = validateUcum("mmol/L");
v.valid; // true: v.canonical is a stable canonical descriptor
validateUcum("mg/dl/").valid; // false: code "TERM_UCUM_INVALID", never a guessed "nearest" unit

ucumEqual("N", "kg.m/s2"); // true: same unit, different spelling
ucumEqual("mmol/L", "mmol.L-1"); // true: annotations inert, operators normalized
ucumEqual("mg", "g"); // false: different units (this is not conversion)
ucumEqual("Cel", "K"); // false: a special (non-linear) unit is never equated with a linear one

The UCUM table is the official ucum-essence.xml (version 2.2), and it is bundled with this package: embedded verbatim in the published build and parsed at runtime. It is copyright ©1999–2024 Regenstrief Institute, Inc., reproduced under the UCUM Copyright Notice and License (https://ucum.org/license); the engine ships no derivative of it. It passes the official UCUM functional-test cases it claims, and claims no more than it runs: see UCUM conformance. The full notice ships in the package at vendor/ucum/NOTICE.md: see What is bundled.

Case-sensitive (c/s) mode only. UCUM's normative interchange mode (the one FHIR/HL7 bind to) is case-sensitive (m = metre, M = mega, Pa = pascal, pA = picoampere). The case-insensitive (c/i) spelling variant is not supported; a c/i-only string returns a typed TERM_UCUM_INVALID (fail-safe, never silently reinterpreted). Magnitude conversion is likewise a deliberate non-goal (recognition and canonicalization only).

UCUM conformance

The claim in full, so a reader can tell what was run from what was not. UCUM permits a qualified claim where an implementation does not cover every kind of case, and asks that the most recent date in the suite's own history be quoted:

  • Case kinds claimed: validation, conversion. Every validation case is executed and its expected valid outcome asserted. The conversion cases are executed for commensurability only: source and destination must reduce to the same dimension. The converted magnitude is not asserted, because magnitude conversion is a deliberate non-goal of this engine.
  • Case kinds not claimed: displayNameGeneration, multiplication. Those cases are not executed and no conformance to them is claimed.
  • Suite date: 3-Feb 2021. The most recent history entry in the vendored UcumFunctionalTests.xml, which is the file the cases are read from.
  • Table: ucum-essence.xml version 2.2, revision-date 2024-06-17. The unit-table release this claim is made against, and the one embedded in the published build.

The package's own test suite holds the claim to those files, so it cannot go stale in silence. It fails if the vendored table's declared version or revision-date stops matching the release named here, if the suite's most recent history date stops matching the date named here, if a kind named here is not one the tests execute, if a named kind's section carries no case, or if this page and vendor/ucum/NOTICE.md state different suite dates.

Terminology ecosystem conformance

The ValueSet layer is measured against HL7's own FHIR terminology ecosystem test cases (HL7/fhir-tx-ecosystem-ig) as well as against tests written here. The claim in full, so a reader can tell what was run from what was not:

  • Snapshot: upstream commit 33d37fc0f8efaed5832032f3a73956f66d4d4f23. The suite publishes no version field, so the commit is the version, and it is the version these counts were produced against. A pinned copy of the cases sits in the repository with their origin and licence recorded beside them; they are third-party material and are not part of the published package.
  • Suites in scope: simple-cases, parameters, validation. Operations in scope: $expand and ValueSet $validate-code. The suite's other suites and its other operations are not run, and no conformance to them is claimed.
  • Cases ran: 101. Passed: 20. Declined: 81. The three add up: every case that ran is exactly one of them. Those three suites declare 112 cases in total; the rest declare a different operation, are marked in the suite for one server's own behaviour, or are held out of the measurement, and none of those is run or counted anywhere above.
  • A declined case is one this library has no answer for, named rather than counted as a pass. It asks for a request parameter the public API takes no argument for (activeOnly, displayLanguage, includeDesignations and others), for HTTP or language negotiation that a library call cannot perform, or it is met by this engine's own typed refusal. Every decline is recorded with its case and its reason.
  • A case that answers differently fails the run, and is never recorded as a decline. The counts above come from a run in which no case answered differently.

The cases are written for a server answering over HTTP, and this package is a library, so they are driven in process through the public API and the comparison is narrower than a server's would be: an expansion is compared on its membership (system, code and display), a $validate-code case on its recorded result. The recorded responses declare their own tolerances and those are honoured as declared; every tolerance applied beyond them is written down beside the counts, on the ground that a tolerance nobody can see is indistinguishable from a pass that was not earned.

The package's own test suite reproduces the run and holds these figures to it, so they cannot go stale in silence.

This is a measurement, not an endorsement. HL7 publishes these cases and reviews outcomes under its own process. Nothing here is an approval, a certification or an endorsement of this package by HL7 or by anyone else.

Navigate the RxNorm drug graph

Load a bring-your-own RxNorm RRF release and walk the ingredient / brand / clinical-drug / dose-form graph. Relationships are read in RxNorm's documented direction (RELA is the relationship the second RXCUI has to the first); navigation follows only authored edges: the engine never synthesizes an inverse, and never fabricates a concept, edge, or NDC.

Authored edges only means the graph you get is RxNorm's, including where its shape surprises you. has_ingredient is authored from the clinical side (SCDC/SCDF/SCDG) to the ingredient IN, and from the branded side (SBD/SBDC/SBDF/SBDG) to the brand name BN rather than to the active ingredient. RxNorm authors no ingredient edge on a clinical drug (SCD) at all, so that query answers with an honest empty set rather than a guess. To reach an active ingredient, walk to the clinical component and take its edge: consistsOf then ingredientsOf. From an SBD, consistsOf returns both its branded component (SBDC, whose ingredient edge is the brand name again) and the clinical SCDC; it is the SCDC that leads to the IN. The precise forms (SCDFP, SBDFP, SCDGP) carry no ingredient edge at all: an SCDFP instead reaches its basis-of-strength substances by has_boss, a one-to-many relation with a different meaning whose targets can be a PIN or a plain IN, and which ingredientsOf does not follow. Read the tty of what comes back rather than assuming it.

A concept is typed by its defining atom, whichever line of RXNCONSO that sits on. PSN, SY and TMSY type a name rather than a concept, so they never establish an RXCUI's term type. An RXCUI no defining atom could type is left out of the graph and reported as TERM_RXNORM_UNTYPED_CONCEPT on graph.warnings, rather than loaded under a synonym's term type: a concept that answers with a term type it does not have is a fabricated claim about a drug, and this engine refuses instead.

import {
  loadRxNormGraph,
  ingredientsOf,
  consistsOf,
  genericFor,
  resolveNdc,
} from "@cosyte/terminology";

const graph = loadRxNormGraph({
  conso: rxnconsoRrf,
  rel: rxnrelRrf,
  sat: rxnsatRrf,
  version: "RXNORM_2026AA",
});

ingredientsOf(graph, "316151"); // an SCDC → { found: true, targets: [ { rxcui: "29046", tty: "IN", name: "lisinopril", … } ] }
ingredientsOf(graph, "314076"); // an SCD → { found: true, targets: [] }, because no such edge is authored
consistsOf(graph, "314076"); // → the SCDC, the first of the two hops to the ingredient
genericFor(graph, "104377"); // an SBD → its SCD, via the authored tradename_of edge
resolveNdc(graph, "00000000001"); // { resolved: true, rxcui: "314076", status: "active", asOf: "RXNORM_2026AA" }
ingredientsOf(graph, "99999999"); // { found: false, code: "TERM_RXNORM_UNKNOWN_RXCUI" }, never a guess

The engine ships no RxNorm release; you supply it (the public-domain Current Prescribable Content subset, or the full BYO release). What it does ship is RxNorm's relationship and term-type names, listed under What is bundled.

The invariants this engine is built on

  • Never fabricate. An unmapped source is a typed unmapped, never a guessed target. Every target returned is drawn verbatim from the supplied map. An unrecognized system resolves to a typed unknown, never a guessed URI.
  • Never invert. A directional map is read in its authored direction only; reverse translation needs an explicit inverse map, never a mechanical inversion.
  • BYO data, engine-only. No code-system release is bundled (a licensing wall, not a gap); the engine operates over your own FHIR resources. The UCUM unit table and the identity sets that are bundled are named, with their copyright, under What is bundled.
  • Liberal load, conservative assertion. Malformed input degrades to a typed diagnostic; a 1:many mapping returns the full candidate set, never collapsed to one.
  • Value-free diagnostic messages. Nothing you configured and nothing your release or resource contained reaches a message, detail, reason or err.stack (not a column name, not a canonical URI, not a unit atom) at any length. What sits beside a message is a locus: a line number, a column count, an index path into your own resource (compose.include[2]), or a fixed token naming which file of a release it came from. The objects are a different matter: a typed unknown/unmapped outcome names the code, unit or NDC you asked about, so log the code and the locus rather than the whole result: a code in patient context can be PHI.
  • Zero runtime dependencies. Dual ESM + CJS. Node stdlib only; built with tsup, validated with attw. A release you load is frozen, its indexes included: a code system's concepts, a GEM's or a complex map's entries and a drug graph's concepts, edges and NDCs are each a read-only view of a map rather than the map itself, so nothing holding one can add, delete or clear an entry, whichever way it tries. A view is not a Map instance, so a model carrying one cannot be structuredCloned or posted to a worker: copy out what you need (new Map(cs.concepts)). The bundled unit table is frozen deeply, atom by atom and definition by definition, because it is shared with the engine's own reducer; its two lookup maps refuse set, delete and clear by name, and an entry forced past that through Map.prototype changes nothing the engine reads, because parseUcum resolves an atom by scanning the frozen atom array rather than through a lookup map. The AST and the reduction that parseUcum and reduce hand back are per-call values you own, and mutating one changes nothing for anyone else.

What is bundled

The engine bundles no code-system release. What it does bundle includes the following, named with its copyright:

  • The UCUM unit table: ucum-essence.xml, version 2.2, revision-date 2024-06-17. The Unified Code for Units of Measure (UCUM) is copyright ©1999–2024 Regenstrief Institute, Inc., all rights reserved, and is reproduced verbatim under the UCUM Copyright Notice and License (https://ucum.org/license). It is embedded byte-for-byte in the published build and parsed at runtime; no modified or derivative copy is distributed. The UCUM Specification is provided "as is" without warranty of any kind: see the License for the full disclaimer. The complete notice ships with this package at vendor/ucum/NOTICE.md.
  • Code-system identity facts: the OID ↔ canonical-URI pairings (SYSTEM_IDENTITIES). These identify the systems; they are not any system's code list.
  • The SNOMED CT concepts the crosswalk resolver names, each with its description: the four map-category concepts in MAP_CATEGORIES (447637006, 447638001, 447639009, 447640006) that a complex-map row's mapCategoryId refers to, and the two gender findings (248152002 Female, 248153007 Male) that a gender IFA rule is written against. Further SNOMED CT identifiers, and ICD-10-CM codes, appear in the API documentation examples, which are distributed in the compiled declaration files and in the build's sourcemaps. SNOMED CT is copyright © International Health Terminology Standards Development Organisation. No SNOMED CT release or refset is bundled: you supply the map rows under your own SNOMED CT licence.
  • The RxNorm relationship and term-type names the drug-graph API is written against: the relationship names in RELA / RELA_INVERSE (has_ingredient, tradename_of, …), and the term-type codes in TERM_TYPES, each with its name as published by the U.S. National Library of Medicine (SCD reads "Semantic Clinical Drug"). Individual RxNorm identifiers also appear in the API documentation examples, with their RxNorm names and term types (for instance RXCUI 316151, "lisinopril 10 MG"), and in this README as well as the places named above. RxNorm is produced by the National Library of Medicine. No RxNorm release is bundled; you supply it.

This section states what is distributed and under whose copyright. It is not legal advice, and makes no claim about whether any particular use of these materials is permitted to you.

License

MIT © Cosyte: this package's own code. The bundled third-party materials above stay under their own copyright and licences; the LICENSE file that ships with this package carries the MIT text and a third-party notices section naming each of them.

About

Terminology engine for Node.js and TypeScript. Runs FHIR $lookup, $validate-code, $translate and $expand over code systems you supply.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages