@cosyte/terminology 0.0.1 → 0.0.2

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.
package/CHANGELOG.md CHANGED
@@ -9,8 +9,202 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
- The first pre-alpha release (`0.0.1`) will ship the initial public API surface. The package begins
13
- its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
12
+ ### Fixed
13
+
14
+ - **The package claimed to bundle no copyrighted content while bundling the UCUM table, and its
15
+ attribution never reached the tarball** (`TERMINOLOGY-LICENSING-ACCURACY`). Four statements shipped
16
+ in `0.0.1`, live on the registry and on docs.cosyte.com, that the tree falsifies:
17
+ 1. the unscoped "**zero copyrighted terminology content is bundled**" claim (README, the JSDoc
18
+ compiled into `dist/index.d.ts` / `dist/index.d.cts`, and four `docs-content/` pages) is
19
+ falsified by `ucum-essence.xml` — copyright Regenstrief Institute, Inc. — being embedded
20
+ byte-for-byte in `dist/index.mjs` and `dist/index.cjs`. The vendored XML carries no copyright
21
+ line of its own, so the shipped copy asserted nothing;
22
+ 2. `vendor/ucum/NOTICE.md` was **absent from `package.json`'s `files`**, so the attribution never
23
+ reached a consumer and the README pointed at a file the package did not contain. Measured by
24
+ packing the base tree: 10 files, no `NOTICE.md`;
25
+ 3. `src/crosswalk/categories.ts` bundles four SNOMED CT map-category concepts — id **and** steward
26
+ description — in the same doc comment that said "no SNOMED CT content is bundled";
27
+ 4. the README listed **LOINC** among "public-domain content packs" one line before listing it as
28
+ copyrighted, and listed **UCUM** among packs that "are not bundled" while bundling it.
29
+
30
+ Founder call 2026-07-29: keep the bundled table, make the claim true. Every consumer surface now
31
+ says **"no code-system release is bundled"** and then names what is: the UCUM unit table
32
+ (`ucum-essence.xml` v2.2, copyright ©1999–2024 Regenstrief Institute, Inc., verbatim under the UCUM
33
+ Copyright Notice and License, with its warranty disclaimer), the code-system identity pairings, and
34
+ the SNOMED CT concepts the crosswalk resolver names — the four map-category concepts **and** the two
35
+ gender findings (`248152002` / `248153007`) a gender `IFA` rule is written against, each with its
36
+ description (copyright © International Health Terminology Standards Development Organisation).
37
+ `vendor/ucum/NOTICE.md` is now in `files` and the packed tarball carries it, which also repairs the
38
+ dangling README pointer. On the README, the declaration files and the docs intro the list is written
39
+ as **open** ("includes the following"); a draft instead called it "three things … exact rather than
40
+ approximate", and that self-certification was falsified from `dist` in one grep by the gender
41
+ findings it had missed.
42
+
43
+ The README and the `src/` doc comments were corrected **together** and the built declaration files
44
+ read back, because fixing one and not the other is what made the previous attempt contradict itself
45
+ and forced a revert to base. Legal conclusions were **cut** rather than reworded, measured on the
46
+ base across `README.md`, `docs-content/`, `src/` and `vendor/`: **"license-clean" ×5**
47
+ (`src/codesystem/fhir.ts`, `src/codesystem/load.ts`, `src/codesystem/types.ts`,
48
+ `src/valueset/types.ts`, `vendor/ucum/NOTICE.md`) and a **permitted-use assertion ×2**
49
+ (`src/ucum/essence.ts`, `vendor/ucum/NOTICE.md`). All seven are now zero. The docs state what is
50
+ distributed and under whose copyright, and say plainly that this is not legal advice. The gate's own
51
+ tarball
52
+ drift tripwire caught the new public surface: `vendor/ucum/NOTICE.md` is now scanned by
53
+ `check:no-internal-refs`, and the internal roadmap citation it carried was removed before it shipped.
54
+
55
+ No runtime behaviour changes.
56
+
57
+ - **A RxNorm concept could load under a term type it does not have.** `loadRxNormGraph` recognized 18
58
+ of RxNorm's term types and typed each concept from whichever recognized `RXNCONSO` atom appeared
59
+ first in the file. Five real term types were not among the 18 (`SCDF`, `SBDF`, `SCDFP`, `SBDFP`,
60
+ `SCDGP`, two of which this package's own documentation already named as subjects of authored
61
+ `has_ingredient` rows), and `PSN` / `SY` / `TMSY` were, so a drug of an unrecognized type that also
62
+ carried a synonym atom loaded under the **synonym's** term type instead of being skipped. `RXCUI`
63
+ 370659, the `SCDF` "hydroxyzine Oral Tablet", loaded as a Tall Man Lettering synonym with no
64
+ warning, and then answered navigation queries under that type. The failure did not need an
65
+ unrecognized type: any concept whose synonym atom preceded its defining one was mislabelled the
66
+ same way, including an ordinary `SCD`.
67
+
68
+ Both halves are fixed together, because either alone leaves the class open. The term-type
69
+ vocabulary now carries all of RxNorm's, each under its published name, and `BPCK` reads "Brand Name
70
+ Pack" as the NLM writes it. And a concept is now typed by a **defining** atom wherever that sits in
71
+ the file: the NLM defines `PSN` / `SY` / `TMSY` as a "synonym of another TTY", so they type a name
72
+ rather than a concept and can no longer establish one. An `RXCUI` that no defining atom could type
73
+ is left out of the graph and reported as a new `TERM_RXNORM_UNTYPED_CONCEPT` warning, which also
74
+ covers concepts that were previously dropped in silence. Refusing is the safer direction: an absent
75
+ concept is already a first-class typed answer, while a concept whose term type reads `TMSY` is a
76
+ fabricated claim about what a drug is, and a caller cannot tell it from a real one.
77
+
78
+ `ingredientsOf` keeps its contract exactly: it follows authored edges only and synthesizes nothing.
79
+ Concepts of the five restored types now load, so they answer with the `has_ingredient` rows their
80
+ release really carries, and the reference now records what those are, verified per relation rather
81
+ than inferred from a sibling: an `SCDF` reaches the ingredient `IN`, an `SBDF` reaches the **brand
82
+ name** rather than the active ingredient, and the precise forms carry no ingredient edge at all.
83
+ An `SCDFP` instead reaches its basis-of-strength substances by `has_boss`, a one-to-many relation
84
+ whose targets can be precise ingredients or plain ingredients, and which `ingredientsOf`
85
+ deliberately does not follow. Also exports `isSynonymTermType`.
86
+
87
+ - **The documented RxNorm ingredient topology now matches the one RxNorm authors.** The package's
88
+ own documentation taught an edge that does not exist. The API reference for `ingredientsOf`, the
89
+ RxNorm type and guide overviews, the quickstart, the concepts guide, the README and the loader's
90
+ worked example all modelled a clinical drug linking straight to its ingredient (`SCD
91
+ has_ingredient IN`). RxNorm authors no such relationship in any release: the ingredient edge is
92
+ authored on the components and dose-form concepts around the clinical drug rather than on the drug
93
+ itself, a branded concept's own ingredient edge lands on its **brand name** rather than on the
94
+ active ingredient, and `IN ingredient_of` reaches those components rather than the drug. Because
95
+ the reference text ships inside the type declarations, an installed copy of the package was
96
+ teaching it too, and `ingredientsOf` on a clinical drug returning nothing read as a gap in the
97
+ engine rather than as the honest answer it is. Those places now state the authored topology, and
98
+ it is stated as the pairings RxNorm authors rather than as a closed set: the clinical side
99
+ (`SCDC`/`SCDF`/`SCDG`) reaches the `IN`, the branded side (`SBD`/`SBDC`/`SBDF`/`SBDG`) reaches the
100
+ `BN`, and the reader is told to check the term type that comes back. They also spell out how to
101
+ reach an _active_ ingredient, which is not the same walk from a clinical drug as from a branded
102
+ one: an `SBD`'s `consists_of` returns its branded component as well as the clinical one, and only
103
+ the clinical one leads to the `IN`. The quickstart carries new executable examples of both
104
+ traversals, so the guidance is checked against the shipped code rather than asserted in prose.
105
+ Behaviour is unchanged: no loader, navigation or resolution result differs.
106
+ - **The npm description described a different package.** `@cosyte/terminology` shipped to the
107
+ registry describing itself as a "Terminology parser, serializer, and builder ... lenient on parse,
108
+ spec-clean on emit" - unmodified scaffold boilerplate, still in use by the packages that really
109
+ are parsers. This one is explicitly **not** a parser, mirrors the FHIR Terminology Module rather
110
+ than a parser API, and has neither a serializer nor a builder. The most-read line of a published
111
+ package therefore named three things it does not have and omitted what it does. The description
112
+ and the `parser` / `serializer` keywords now say what the engine is and what it operates over.
113
+
114
+ - **`VERSION` now reports the version you actually installed.** `@cosyte/terminology@0.0.1` shipped
115
+ exporting `VERSION === "0.0.0"`: `package.json` was bumped by Changesets, the constant in the source
116
+ was not. `docs-content/installation.md` documents printing exactly that constant as the install smoke
117
+ test, so the one check an installer is told to run was the one that lied. The constant now reads
118
+ `0.0.1`, and it can no longer drift: the release `version` script rewrites it from `package.json`, and
119
+ a test compares the two, so a skipped sync fails the build instead of publishing.
120
+ - **Docs stated the wrong publish status in five places.** The README status block, the README install
121
+ note, `docs-content/intro.md`, `docs-content/installation.md`, and the project guide all said
122
+ `@cosyte/terminology` was "not yet published to npm" and told readers to consume it from source or a
123
+ workspace link. It has been on npm since `0.0.1`. All five now say the package is published, and
124
+ deliberately do not name a version: a version literal in prose is the same drift this release just
125
+ built a machine guard against, and the registry is the authority. (The historical `[0.0.1]` entry
126
+ below is left as written: it records what the docs said at the time, hours before the release
127
+ landed.)
128
+
129
+ ### Changed
130
+
131
+ - **Internal project bookkeeping is gone from every surface a consumer reads, and a gate now keeps
132
+ it out.** Per the founder directive of 2026-07-27, what a consumer reads says what the software
133
+ does and what changed; item identifiers, phase and roadmap-section language, ADR numbers and
134
+ meta-repo paths belong in this file, the changeset, the commit and the roadmap. Measured on
135
+ `1158f96` with the final rule set: the public markdown carried 10 lines across four
136
+ `docs-content/` pages, `src/` doc comments produced 154 gate hits across 28 of the 38 source
137
+ files (91 from the line pass and 63 from the paragraph-reflow pass, which re-reports all but one
138
+ of the same places), and the npm `description`/`keywords` and `src/` string literals carried
139
+ none. The doc comments are the
140
+ surface that matters most and the one no docs review looks at: they compile verbatim into
141
+ `dist/index.d.ts` and `dist/index.d.cts`, so **89 lines of the published declaration files**
142
+ carried this text into every consumer's editor. All are now zero.
143
+
144
+ Two shapes no rule catches were cleared by hand, and they are recorded as places rather than as a
145
+ count: clause-terminal uses of "phase" (in the README, the troubleshooting page, and the
146
+ concept-map, crosswalk, module and RxNorm reference docs), and two bare roadmap-section citations
147
+ written without the word "roadmap" next to them. **A zero from a rule set is not a zero**, which
148
+ is why the by-hand half is stated at all.
149
+
150
+ Two statements were **restated rather than deleted**, because removing the planning pointer alone
151
+ would have widened each into a capability the code does not provide: subsumption is documented as
152
+ not computed across two separate releases (it reads the loaded system's own `parent` edges), and
153
+ the `ICD10CM_ORDER_FILE_FIELDS` preset is documented as not confirmed against an authoritative
154
+ machine-readable source. The NLM RxNorm Technical Documentation citations were
155
+ deliberately left standing: they are the normative grounding for the `RXNREL` edge-direction
156
+ convention, and a rule keyed on `§` alone would have deleted the reference a consumer needs in
157
+ order to check that direction. A self-test now asserts that no rule matches them, so closing that
158
+ gap has to be a decision.
159
+
160
+ - **`pnpm check:no-internal-refs` is a new repository gate, and it runs in its own CI job.** It
161
+ scans `README.md`, `LICENSE`, `docs-content/`, the npm `description` and `keywords`, `src/` doc
162
+ comments and `src/` string literals, line by line and paragraph-joined, and refuses to report
163
+ green from a scan that did not read all of its input. It deliberately does **not** scan
164
+ `CHANGELOG.md`, `.changeset/`, `CLAUDE.md` or `//` comments, which is where the identifiers
165
+ belong. Its rules key on known project prefixes and never on the `WORD-N` shape: measured here, a
166
+ shape rule matches 89 hyphenated uppercase tokens on this tree and **all 89 are the consumer's
167
+ reference material** (`ICD-10-CM`, `ICD-9`, `RFC-4180`, and the UCUM expressions `OHM-1`, `CM-1`
168
+ and `KG-1.S-2`, which are units with negative exponents), while the prefix-keyed rule matches
169
+ none. Negative self-tests pin that, so a later simplification reds instead of silently deleting a
170
+ code system's identifier from a terminology engine's documentation. One rule diverges from the
171
+ sibling copies on the same reasoning: the "unit of work" rule no longer keys on "the", because a
172
+ fixed-width order file's **field slice** is this package's public API and the sibling pattern was
173
+ wrong eight times out of nine here.
174
+
175
+ - **The RxNorm drug graph's edge direction is now pinned by tests in both directions.** RxNorm
176
+ relationships are directional, and reading one backwards is a wrong-medication defect rather than a
177
+ cosmetic one: the ingredient of a tablet would come back as a tablet that is an "ingredient of" its
178
+ own ingredient, and the generic behind a brand would come back as the brand behind a generic. The
179
+ convention is now pinned per relation family (ingredient, tradename, dose form, components and
180
+ packs), forward and reverse, against the sample relationship rows published in the RxNorm technical
181
+ documentation, so an inverted reading cannot pass by trading one assertion for another. The pinned
182
+ fixtures follow the topology RxNorm actually authors, including the two places it surprises people:
183
+ a clinical drug carries no direct ingredient edge (the ingredient is reached through its
184
+ component), and a branded drug's direct ingredient edge points at its brand name. Behaviour is
185
+ unchanged: the loader and the navigation surface answer exactly as they did before.
186
+ - **`VERSION` is declared `string` rather than the version literal.** Its emitted type was previously
187
+ narrowed to the exact release string, so every version bump was a `.d.ts` change and code comparing
188
+ `VERSION` against another string could be rejected by the type checker. Widening it makes the
189
+ declared type stable across releases. Runtime value is unchanged.
190
+
191
+ ### Security
192
+
193
+ - **The CI checks that run on a pull request now block the merge.** Until now `@cosyte/terminology`
194
+ had no branch-protection ruleset at all, so `ci` (typecheck, lint, format, PHI scan, tests, coverage
195
+ gate, build, `attw`, dual ESM/CJS smoke) and CodeQL could all go red and the merge would still land
196
+ on `main`, and `main` publishes. A repository ruleset now requires those checks, restricted to the
197
+ GitHub Actions app so a status of the same name cannot be posted by anything else, and blocks branch
198
+ deletion and force-push on `main`. Scope of the claim: this makes a red check binding, it does not
199
+ make a check correct, and nothing inside this repository can observe the ruleset. See the note in
200
+ `.github/workflows/ci.yml` before splitting a required job.
201
+ - **Dependency updates are now watched.** The repository had no Dependabot configuration, so its zero
202
+ open update PRs meant nothing was looking, not that nothing was stale.
203
+
204
+ ## [0.0.1] - 2026-07-21
205
+
206
+ The first pre-alpha release. The package begins its public history at `0.0.x`, per the cosyte version
207
+ ladder (`0.0.x` until first alpha).
14
208
 
15
209
  ### Security
16
210
 
@@ -242,8 +436,5 @@ its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until firs
242
436
  `WARNING_CODES` entry, and the parse-oriented option/warning types). This package is an engine, not
243
437
  a parser; that placeholder surface never shipped.
244
438
 
245
- ### Fixed
246
-
247
- ### Security
248
-
249
- [Unreleased]: https://github.com/cosyte/terminology/commits/main
439
+ [Unreleased]: https://github.com/cosyte/terminology/compare/v0.0.1...HEAD
440
+ [0.0.1]: https://github.com/cosyte/terminology/releases/tag/v0.0.1
package/README.md CHANGED
@@ -6,12 +6,14 @@
6
6
  `@cosyte/terminology` is **not a wire parser**. It mirrors the FHIR **Terminology Module**
7
7
  (`$translate`, `$lookup`, `$validate-code`, `$expand`, …), operating over **consumer-supplied** FHIR
8
8
  resources. It is the sibling engine `@cosyte/transform` and, later, the parsers' code-system
9
- recognition consume. It ships the **engine, never copyrighted terminology content** — SNOMED CT, CPT,
10
- full LOINC/UMLS, and VSAC value sets are strictly bring-your-own; code-system _identities_ (OID ↔
11
- canonical URI) are published facts, grounded firsthand and encoded.
12
-
13
- > **Status:** pre-alpha (`0.0.x`), **not yet published to npm.** The **engine is complete** — every
14
- > operation below ships today. What is still to come is bundled _content_, not capability. The surface:
9
+ recognition consume. It ships the **engine, and no code-system release** — SNOMED CT, CPT, LOINC,
10
+ UMLS/RxNorm, and VSAC value sets are strictly bring-your-own. It is not content-free, though: what it
11
+ _does_ bundle — the UCUM unit table, the code-system _identities_ (OID ↔ canonical URI), and the
12
+ SNOMED CT concepts the crosswalk resolver names — is listed with its copyright under
13
+ [What is bundled](#what-is-bundled).
14
+
15
+ > **Status:** pre-alpha (`0.0.x`), **published on npm.** The **engine is complete** — every
16
+ > operation below ships today. The surface:
15
17
  >
16
18
  > - the code-system **identity resolver** (`resolveSystem`) and the ConceptMap **`$translate`** engine
17
19
  > (`loadConceptMap` / `translate`);
@@ -23,22 +25,23 @@ canonical URI) are published facts, grounded firsthand and encoded.
23
25
  > magnitude conversion**) — the official UCUM functional-test suite is the conformance gate;
24
26
  > - the **crosswalk resolvers** — the CMS **ICD-9↔ICD-10 GEMs** (`loadGems` / `applyGem`,
25
27
  > public-domain) and the NLM **SNOMED CT → ICD-10-CM complex map** (`loadComplexMap` /
26
- > `applyComplexMap`, BYO — zero SNOMED content bundled), never inverted;
28
+ > `applyComplexMap`, BYO — no SNOMED CT refset bundled), never inverted;
27
29
  > - the **RxNorm drug graph** (`loadRxNormGraph` + `ingredientsOf` / `genericFor` / `brandsFor` /
28
30
  > `doseFormsOf` / `consistsOf` / `resolveNdc` / `approximateMatch`) over a **BYO** RxNorm RRF release,
29
31
  > edges read in RxNorm's documented direction and never inverted, an absent `RXCUI`/NDC typed, never
30
32
  > fabricated.
31
33
  >
32
- > **Bring your own data.** The engine is whole; **no code-system _content_ is bundled yet.** It runs
33
- > over the FHIR resources and standard releases _you_ supply. The bundleable public-domain content packs
34
- > (RxNorm Prescribable, ICD-10-CM, UCUM, LOINC) are still to come — they are blocked on getting a genuine
35
- > verbatim public-domain release into the build, not on engine work — and the copyrighted content
36
- > (SNOMED CT / CPT / full LOINC / UMLS / VSAC) is bring-your-own permanently by license.
34
+ > **Bring your own data.** The engine is whole; **no code-system release is bundled.** It runs over
35
+ > the FHIR resources and standard releases _you_ supply — SNOMED CT, CPT, LOINC, UMLS/RxNorm, the CMS
36
+ > GEM files, the NLM complex-map refset and VSAC value sets are all bring-your-own, under whatever
37
+ > terms their steward sets. The content packs that would change that (RxNorm Prescribable, ICD-10-CM)
38
+ > are not bundled either. For the things that **are** bundled, and under whose copyright, see
39
+ > [What is bundled](#what-is-bundled).
37
40
 
38
41
  ## Install
39
42
 
40
- > **Not on npm yet.** `@cosyte/terminology` is pre-alpha and **unpublished** — the command below is how
41
- > you will install it once the first `0.0.x` release lands. Until then the package is source-only.
43
+ > **On npm.** `@cosyte/terminology` is **published**, on the pre-alpha `0.0.x` ladder. The command
44
+ > below installs the real package; the registry, not this page, is the authority on the exact version.
42
45
 
43
46
  ```bash
44
47
  npm install @cosyte/terminology
@@ -111,9 +114,12 @@ ucumEqual("mg", "g"); // false — different units (this is not conversion)
111
114
  ucumEqual("Cel", "K"); // false — a special (non-linear) unit is never equated with a linear one
112
115
  ```
113
116
 
114
- The UCUM table is the official `ucum-essence.xml`, vendored **verbatim** and parsed at runtime; the
115
- engine ships no derivative of it and passes the official UCUM functional-test suite. See
116
- `vendor/ucum/NOTICE.md`.
117
+ The UCUM table is the official `ucum-essence.xml` (version 2.2), and it **is bundled with this
118
+ package** — embedded verbatim in the published build and parsed at runtime. It is copyright
119
+ ©1999–2024 Regenstrief Institute, Inc., reproduced under the UCUM Copyright Notice and License
120
+ (<https://ucum.org/license>); the engine ships no derivative of it and passes the official UCUM
121
+ functional-test suite. The full notice ships in the package at `vendor/ucum/NOTICE.md` — see
122
+ [What is bundled](#what-is-bundled).
117
123
 
118
124
  > **Case-sensitive (c/s) mode only.** UCUM's normative interchange mode — the one FHIR/HL7 bind to —
119
125
  > is case-sensitive (`m` = metre, `M` = mega, `Pa` = pascal, `pA` = picoampere). The
@@ -128,8 +134,34 @@ dose-form graph. Relationships are read in RxNorm's documented direction (`RELA`
128
134
  the **second** `RXCUI` has to the **first**); navigation follows only authored edges — the engine
129
135
  never synthesizes an inverse, and never fabricates a concept, edge, or NDC.
130
136
 
137
+ Authored edges only means the graph you get is RxNorm's, including where its shape surprises you.
138
+ `has_ingredient` is authored from the **clinical** side (`SCDC`/`SCDF`/`SCDG`) to the ingredient
139
+ `IN`, and from the **branded** side (`SBD`/`SBDC`/`SBDF`/`SBDG`) to the **brand name** `BN` rather
140
+ than to the active ingredient. RxNorm authors no ingredient edge on a clinical drug (`SCD`) at all,
141
+ so that query answers with an honest empty set rather than a guess. To reach an active ingredient,
142
+ walk to the **clinical** component and take its edge: `consistsOf` then `ingredientsOf`. From an
143
+ `SBD`, `consistsOf` returns both its branded component (`SBDC`, whose ingredient edge is the brand
144
+ name again) and the clinical `SCDC`; it is the `SCDC` that leads to the `IN`. The **precise** forms
145
+ (`SCDFP`, `SBDFP`, `SCDGP`) carry no ingredient edge at all: an `SCDFP` instead reaches its
146
+ basis-of-strength substances by `has_boss`, a one-to-many relation with a different meaning whose
147
+ targets can be a `PIN` or a plain `IN`, and which `ingredientsOf` does not follow. Read the `tty` of
148
+ what comes back rather than assuming it.
149
+
150
+ A concept is typed by its **defining** atom, whichever line of `RXNCONSO` that sits on. `PSN`, `SY`
151
+ and `TMSY` type a _name_ rather than a concept, so they never establish an `RXCUI`'s term type. An
152
+ `RXCUI` no defining atom could type is left out of the graph and reported as
153
+ `TERM_RXNORM_UNTYPED_CONCEPT` on `graph.warnings`, rather than loaded under a synonym's term type:
154
+ a concept that answers with a term type it does not have is a fabricated claim about a drug, and
155
+ this engine refuses instead.
156
+
131
157
  ```ts
132
- import { loadRxNormGraph, ingredientsOf, genericFor, resolveNdc } from "@cosyte/terminology";
158
+ import {
159
+ loadRxNormGraph,
160
+ ingredientsOf,
161
+ consistsOf,
162
+ genericFor,
163
+ resolveNdc,
164
+ } from "@cosyte/terminology";
133
165
 
134
166
  const graph = loadRxNormGraph({
135
167
  conso: rxnconsoRrf,
@@ -138,14 +170,16 @@ const graph = loadRxNormGraph({
138
170
  version: "RXNORM_2026AA",
139
171
  });
140
172
 
141
- ingredientsOf(graph, "314076"); // { found: true, targets: [ { rxcui: "29046", tty: "IN", name: "lisinopril", … } ] }
142
- genericFor(graph, "311354"); // an SBD → its SCD, via the authored tradename_of edge
173
+ ingredientsOf(graph, "316151"); // an SCDC → { found: true, targets: [ { rxcui: "29046", tty: "IN", name: "lisinopril", … } ] }
174
+ ingredientsOf(graph, "314076"); // an SCD → { found: true, targets: [] }, because no such edge is authored
175
+ consistsOf(graph, "314076"); // → the SCDC, the first of the two hops to the ingredient
176
+ genericFor(graph, "104377"); // an SBD → its SCD, via the authored tradename_of edge
143
177
  resolveNdc(graph, "00000000001"); // { resolved: true, rxcui: "314076", status: "active", asOf: "RXNORM_2026AA" }
144
178
  ingredientsOf(graph, "99999999"); // { found: false, code: "TERM_RXNORM_UNKNOWN_RXCUI" } — never a guess
145
179
  ```
146
180
 
147
181
  The engine ships **no** RxNorm content — you supply the release (the public-domain Current Prescribable
148
- Content subset, or the full BYO release). Bundling the public-domain pack is a later phase.
182
+ Content subset, or the full BYO release).
149
183
 
150
184
  ## The invariants this engine is built on
151
185
 
@@ -154,8 +188,9 @@ Content subset, or the full BYO release). Bundling the public-domain pack is a l
154
188
  `unknown`, never a guessed URI.
155
189
  - **Never invert.** A directional map is read in its authored direction only; reverse translation
156
190
  needs an explicit inverse map, never a mechanical inversion.
157
- - **BYO data, engine-only.** Zero copyrighted terminology content is bundled (a licensing wall, not a
158
- gap); the engine operates over your own FHIR resources.
191
+ - **BYO data, engine-only.** No code-system release is bundled (a licensing wall, not a gap); the
192
+ engine operates over your own FHIR resources. The UCUM unit table and the two identity sets that
193
+ **are** bundled are named, with their copyright, under [What is bundled](#what-is-bundled).
159
194
  - **Liberal load, conservative assertion.** Malformed input degrades to a typed diagnostic; a 1:many
160
195
  mapping returns the full candidate set, never collapsed to one.
161
196
  - **Value-free diagnostics.** A diagnostic carries a code + system + version, never patient context —
@@ -163,6 +198,31 @@ Content subset, or the full BYO release). Bundling the public-domain pack is a l
163
198
  - **Zero runtime dependencies. Dual ESM + CJS.** Node stdlib only; built with `tsup`, validated with
164
199
  `attw`. Immutable by construction (every returned value is deep-frozen).
165
200
 
201
+ ## What is bundled
202
+
203
+ The engine bundles **no code-system release**. What it _does_ bundle includes the following, named
204
+ with its copyright:
205
+
206
+ - **The UCUM unit table** — `ucum-essence.xml`, version 2.2, revision-date 2024-06-17. The Unified
207
+ Code for Units of Measure (UCUM) is copyright ©1999–2024 Regenstrief Institute, Inc., all rights
208
+ reserved, and is reproduced **verbatim** under the UCUM Copyright Notice and License
209
+ (<https://ucum.org/license>). It is embedded byte-for-byte in the published build and parsed at
210
+ runtime; no modified or derivative copy is distributed. The UCUM Specification is provided "as is"
211
+ **without warranty of any kind** — see the License for the full disclaimer. The complete notice
212
+ ships with this package at `vendor/ucum/NOTICE.md`.
213
+ - **Code-system identity facts** — the OID ↔ canonical-URI pairings (`SYSTEM_IDENTITIES`). These
214
+ identify the systems; they are not any system's code list.
215
+ - **The SNOMED CT concepts the crosswalk resolver names**, each with its description: the four
216
+ map-category concepts in `MAP_CATEGORIES` (`447637006`, `447638001`, `447639009`, `447640006`) that
217
+ a complex-map row's `mapCategoryId` refers to, and the two gender findings (`248152002` Female,
218
+ `248153007` Male) that a gender `IFA` rule is written against. SNOMED CT is copyright
219
+ © International Health Terminology Standards Development Organisation. **No SNOMED CT release or
220
+ refset is bundled** — you supply the map rows under your own SNOMED CT licence.
221
+
222
+ This section states what is distributed and under whose copyright. It is not legal advice, and makes
223
+ no claim about whether any particular use of these materials is permitted to you.
224
+
166
225
  ## License
167
226
 
168
- MIT © Cosyte
227
+ MIT © Cosyte — this package's own code. The bundled third-party materials above stay under their own
228
+ copyright and licences.
package/dist/index.cjs CHANGED
@@ -75,13 +75,13 @@ var DIAGNOSTIC_CODES = {
75
75
  * code system was not supplied, an unresolvable referenced value set, or a `filter` operator the
76
76
  * engine does not implement. Surfaced as a typed outcome that marks the expansion **incomplete**;
77
77
  * the engine **never** fabricates a member and **never** returns a silently-empty "no members"
78
- * answer for a part it could not compute (roadmap §4.4 — a false "not a member" is a clinical error).
78
+ * answer for a part it could not compute (a false "not a member" is a clinical error).
79
79
  */
80
80
  TERM_VALUESET_CANNOT_EXPAND: "TERM_VALUESET_CANNOT_EXPAND",
81
81
  /**
82
82
  * A **pre-computed** `ValueSet.expansion` is **incomplete** — its `total` exceeds the number of
83
- * `contains` entries, or it is flagged too-costly (the VSAC `$expand` >1200-code truncation hazard,
84
- * roadmap §4.4). The engine **never treats a truncated expansion as complete membership**: a code
83
+ * `contains` entries, or it is flagged too-costly (the VSAC `$expand` >1200-code truncation
84
+ * hazard). The engine **never treats a truncated expansion as complete membership**: a code
85
85
  * absent from a truncated expansion is `undetermined`, never a confident "not a member".
86
86
  */
87
87
  TERM_VALUESET_EXPANSION_TRUNCATED: "TERM_VALUESET_EXPANSION_TRUNCATED",
@@ -89,7 +89,7 @@ var DIAGNOSTIC_CODES = {
89
89
  * A UCUM unit expression is **not valid** — it does not parse against the UCUM grammar, or it
90
90
  * names an atom absent from the vendored UCUM table. A **first-class typed outcome, never an
91
91
  * error and never a guess**: {@link validateUcum} returns `{ valid: false, reason }`, never a
92
- * coerced or "nearest" unit (the never-fabricate invariant, applied to units — roadmap §4.3). The
92
+ * coerced or "nearest" unit (the never-fabricate invariant, applied to units). The
93
93
  * `reason` is value-free (a grammar fault or an unknown-atom shape), never PHI.
94
94
  */
95
95
  TERM_UCUM_INVALID: "TERM_UCUM_INVALID",
@@ -97,7 +97,7 @@ var DIAGNOSTIC_CODES = {
97
97
  * A crosswalk source code has an **authored No-Map** — the steward declares it has no valid target
98
98
  * in the other classification (a GEM `NoDx`/`NoPCS` sentinel, or a SNOMED complex-map `447638001`
99
99
  * "cannot be classified" category). A **first-class typed outcome, never an error and never a
100
- * guess** (the never-fabricate invariant, applied to crosswalks — roadmap §4.1). Distinct from
100
+ * guess** (the never-fabricate invariant, applied to crosswalks). Distinct from
101
101
  * {@link TERM_CROSSWALK_UNMAPPED}: the source *is* in the map, and the map says "no target".
102
102
  */
103
103
  TERM_CROSSWALK_NO_MAP: "TERM_CROSSWALK_NO_MAP",
@@ -111,7 +111,7 @@ var DIAGNOSTIC_CODES = {
111
111
  /**
112
112
  * A SNOMED→ICD-10-CM complex-map group's decision needs **patient context** (an `IFA` age band or
113
113
  * gender) the caller did not supply. The engine surfaces the candidate rules + advice and **refuses
114
- * to pick a branch** it lacks the data for (roadmap §4.1) — never a silently-chosen target.
114
+ * to pick a branch** it lacks the data for — never a silently-chosen target.
115
115
  */
116
116
  TERM_CROSSWALK_CONTEXT_REQUIRED: "TERM_CROSSWALK_CONTEXT_REQUIRED",
117
117
  /**
@@ -133,17 +133,27 @@ var DIAGNOSTIC_CODES = {
133
133
  * **silently** — they are expected, not faults.
134
134
  */
135
135
  TERM_RXNORM_MALFORMED_ROW: "TERM_RXNORM_MALFORMED_ROW",
136
+ /**
137
+ * An `RXCUI` appeared in the release's `SAB=RXNORM` atoms but **none of them could establish its
138
+ * term type**, so it is **not** in the graph. Either every atom carried a synonym-class `TTY`
139
+ * (`PSN`/`SY`/`TMSY`, which Appendix 5 defines as a "synonym of another TTY" and which therefore
140
+ * types a *name* rather than a concept) or every atom carried a `TTY` this engine does not model.
141
+ * The concept is skipped rather than typed from a synonym atom: an absent concept is already a
142
+ * first-class typed answer (`TERM_RXNORM_UNKNOWN_RXCUI`), while a concept whose term type reads
143
+ * `TMSY` is a fabricated claim about what a drug *is* that a caller cannot detect.
144
+ */
145
+ TERM_RXNORM_UNTYPED_CONCEPT: "TERM_RXNORM_UNTYPED_CONCEPT",
136
146
  /**
137
147
  * A RxNorm graph navigation was asked about an `RXCUI` **absent from the loaded release** — a
138
148
  * first-class typed outcome, never a fabricated concept and never an empty "success" (the
139
- * never-fabricate invariant, applied to the drug graph — roadmap §4.2). Distinct from a *present*
149
+ * never-fabricate invariant, applied to the drug graph). Distinct from a *present*
140
150
  * concept that simply has no edge of the requested relationship (that is a found result with empty
141
151
  * targets).
142
152
  */
143
153
  TERM_RXNORM_UNKNOWN_RXCUI: "TERM_RXNORM_UNKNOWN_RXCUI",
144
154
  /**
145
155
  * An NDC could **not be resolved** to any `RXCUI` in the loaded RxNorm release — a typed, surfaced
146
- * absence, never a guessed `RXCUI`. NDC↔RXCUI is many:1 and temporal (roadmap §4.2), so a resolution
156
+ * absence, never a guessed `RXCUI`. NDC↔RXCUI is many:1 and temporal, so a resolution
147
157
  * is always release-scoped; an NDC not present in the loaded release is this diagnostic.
148
158
  */
149
159
  TERM_RXNORM_NDC_UNMAPPED: "TERM_RXNORM_NDC_UNMAPPED"
@@ -178,7 +188,7 @@ var FATAL_CODES = {
178
188
  /**
179
189
  * An **inversion** of a directional crosswalk was requested — a forbidden operation. The GEMs and
180
190
  * the SNOMED→ICD-10-CM map are authoritative in **one direction only**; a forward map is not the
181
- * inverse of the backward map (roadmap §4.1). Thrown by {@link ../crosswalk/gems.invertGem} so the
191
+ * inverse of the backward map. Thrown by {@link ../crosswalk/gems.invertGem} so the
182
192
  * never-invert refusal is a first-class, discoverable contract rather than a silent absence.
183
193
  */
184
194
  TERM_MAP_NOT_INVERTIBLE: "TERM_MAP_NOT_INVERTIBLE"
@@ -4360,12 +4370,17 @@ var TERM_TYPES = Object.freeze({
4360
4370
  BN: "Brand Name",
4361
4371
  SCDC: "Semantic Clinical Drug Component",
4362
4372
  SBDC: "Semantic Branded Drug Component",
4373
+ SCDF: "Semantic Clinical Drug Form",
4374
+ SBDF: "Semantic Branded Drug Form",
4375
+ SCDFP: "Semantic Clinical Drug Form Precise",
4376
+ SBDFP: "Semantic Branded Drug Form Precise",
4363
4377
  SCD: "Semantic Clinical Drug",
4364
4378
  SBD: "Semantic Branded Drug",
4365
4379
  SCDG: "Semantic Clinical Drug Group",
4366
4380
  SBDG: "Semantic Branded Drug Group",
4381
+ SCDGP: "Semantic Clinical Drug Form Group Precise",
4367
4382
  GPCK: "Generic Pack",
4368
- BPCK: "Branded Pack",
4383
+ BPCK: "Brand Name Pack",
4369
4384
  DF: "Dose Form",
4370
4385
  DFG: "Dose Form Group",
4371
4386
  PSN: "Prescribable Name",
@@ -4374,9 +4389,13 @@ var TERM_TYPES = Object.freeze({
4374
4389
  ET: "Entry Term"
4375
4390
  });
4376
4391
  var KNOWN = new Set(Object.keys(TERM_TYPES));
4392
+ var SYNONYM = /* @__PURE__ */ new Set(["PSN", "SY", "TMSY"]);
4377
4393
  function asTermType(tty) {
4378
4394
  return KNOWN.has(tty) ? tty : void 0;
4379
4395
  }
4396
+ function isSynonymTermType(tty) {
4397
+ return SYNONYM.has(tty);
4398
+ }
4380
4399
 
4381
4400
  // src/rxnorm/load.ts
4382
4401
  var SAB_RXNORM = "RXNORM";
@@ -4390,13 +4409,13 @@ function cell2(cells, index) {
4390
4409
  }
4391
4410
  function parseConso(content, warnings) {
4392
4411
  const concepts = /* @__PURE__ */ new Map();
4393
- const lines = content.split(/\r?\n/);
4394
- for (let i = 0; i < lines.length; i++) {
4395
- const raw = lines[i] ?? "";
4412
+ const conso = [];
4413
+ const untyped = /* @__PURE__ */ new Map();
4414
+ for (const [i, raw] of content.split(/\r?\n/).entries()) {
4396
4415
  if (raw === "") continue;
4397
4416
  const cells = parseRrfLine(raw);
4398
4417
  if (cells.length <= CONSO.STR) {
4399
- warnings.push(
4418
+ conso.push(
4400
4419
  Object.freeze({
4401
4420
  code: "TERM_RXNORM_MALFORMED_ROW",
4402
4421
  file: "RXNCONSO",
@@ -4407,11 +4426,9 @@ function parseConso(content, warnings) {
4407
4426
  continue;
4408
4427
  }
4409
4428
  if (cell2(cells, CONSO.SAB) !== SAB_RXNORM) continue;
4410
- const tty = asTermType(cell2(cells, CONSO.TTY) ?? "");
4411
- if (tty === void 0) continue;
4412
4429
  const rxcui = cell2(cells, CONSO.RXCUI);
4413
4430
  if (rxcui === void 0 || rxcui === "") {
4414
- warnings.push(
4431
+ conso.push(
4415
4432
  Object.freeze({
4416
4433
  code: "TERM_RXNORM_MALFORMED_ROW",
4417
4434
  file: "RXNCONSO",
@@ -4421,7 +4438,13 @@ function parseConso(content, warnings) {
4421
4438
  );
4422
4439
  continue;
4423
4440
  }
4441
+ const tty = asTermType(cell2(cells, CONSO.TTY) ?? "");
4442
+ if (tty === void 0 || isSynonymTermType(tty)) {
4443
+ if (!concepts.has(rxcui) && !untyped.has(rxcui)) untyped.set(rxcui, i + 1);
4444
+ continue;
4445
+ }
4424
4446
  if (concepts.has(rxcui)) continue;
4447
+ untyped.delete(rxcui);
4425
4448
  const name = cell2(cells, CONSO.STR) ?? "";
4426
4449
  const suppress = cell2(cells, CONSO.SUPPRESS) ?? "N";
4427
4450
  concepts.set(
@@ -4429,14 +4452,24 @@ function parseConso(content, warnings) {
4429
4452
  Object.freeze({ rxcui, tty, name, suppressed: suppress !== "" && suppress !== "N" })
4430
4453
  );
4431
4454
  }
4455
+ for (const line of untyped.values()) {
4456
+ conso.push(
4457
+ Object.freeze({
4458
+ code: "TERM_RXNORM_UNTYPED_CONCEPT",
4459
+ file: "RXNCONSO",
4460
+ line,
4461
+ detail: "no RXNORM atom established this RXCUI's term type (every atom was synonym-class or an unmodelled TTY); skipped rather than typed from a synonym"
4462
+ })
4463
+ );
4464
+ }
4465
+ conso.sort((a, b) => a.line - b.line);
4466
+ for (const w of conso) warnings.push(w);
4432
4467
  return concepts;
4433
4468
  }
4434
4469
  function parseRel(content, warnings) {
4435
4470
  const edges = /* @__PURE__ */ new Map();
4436
4471
  let edgeCount = 0;
4437
- const lines = content.split(/\r?\n/);
4438
- for (let i = 0; i < lines.length; i++) {
4439
- const raw = lines[i] ?? "";
4472
+ for (const [i, raw] of content.split(/\r?\n/).entries()) {
4440
4473
  if (raw === "") continue;
4441
4474
  const cells = parseRrfLine(raw);
4442
4475
  if (cells.length <= REL.RELA) {
@@ -4465,9 +4498,7 @@ function parseRel(content, warnings) {
4465
4498
  }
4466
4499
  function parseNdcs(content, warnings) {
4467
4500
  const ndcs = /* @__PURE__ */ new Map();
4468
- const lines = content.split(/\r?\n/);
4469
- for (let i = 0; i < lines.length; i++) {
4470
- const raw = lines[i] ?? "";
4501
+ for (const [i, raw] of content.split(/\r?\n/).entries()) {
4471
4502
  if (raw === "") continue;
4472
4503
  const cells = parseRrfLine(raw);
4473
4504
  if (cells.length <= SAT.ATV) {
@@ -4519,9 +4550,17 @@ function loadRxNormGraph(source) {
4519
4550
 
4520
4551
  // src/rxnorm/rela.ts
4521
4552
  var RELA = {
4522
- /** `has_ingredient` — subject (a drug/component) *has ingredient* object (an `IN`/`PIN`). */
4553
+ /**
4554
+ * `has_ingredient`: subject *has ingredient* object. Authored from the clinical side
4555
+ * (`SCDC`/`SCDF`/`SCDG`) to an `IN` and from the branded side (`SBD`/`SBDC`/`SBDF`/`SBDG`) to a
4556
+ * `BN`; RxNorm authors no `SCD ⟶ IN` row.
4557
+ */
4523
4558
  HAS_INGREDIENT: "has_ingredient",
4524
- /** `ingredient_of` — subject (an `IN`/`PIN`) *is an ingredient of* object (a drug/component). */
4559
+ /**
4560
+ * `ingredient_of`: subject *is an ingredient of* object. The authored inverse, so an `IN` reaches
4561
+ * clinical components and dose forms and a `BN` reaches branded concepts (the `SBD` included).
4562
+ * Neither reaches an `SCD`.
4563
+ */
4525
4564
  INGREDIENT_OF: "ingredient_of",
4526
4565
  /** `has_precise_ingredient` — subject *has precise ingredient* object (a `PIN`). */
4527
4566
  HAS_PRECISE_INGREDIENT: "has_precise_ingredient",
@@ -4664,7 +4703,7 @@ function approximateMatch(graph, query, options = {}) {
4664
4703
  }
4665
4704
 
4666
4705
  // src/index.ts
4667
- var VERSION = "0.0.0";
4706
+ var VERSION = "0.0.2";
4668
4707
 
4669
4708
  exports.COMPLEX_MAP_SOURCE_SYSTEM = COMPLEX_MAP_SOURCE_SYSTEM;
4670
4709
  exports.DIAGNOSTIC_CODES = DIAGNOSTIC_CODES;
@@ -4706,6 +4745,7 @@ exports.ingredientsOf = ingredientsOf;
4706
4745
  exports.invertGem = invertGem;
4707
4746
  exports.isA = isA;
4708
4747
  exports.isJsonObject = isJsonObject;
4748
+ exports.isSynonymTermType = isSynonymTermType;
4709
4749
  exports.isUnknownSystem = isUnknownSystem;
4710
4750
  exports.loadCodeSystem = loadCodeSystem;
4711
4751
  exports.loadComplexMap = loadComplexMap;