@cosyte/terminology 0.0.4 → 0.0.6

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,237 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Changed
13
+
14
+ - **Breaking: `ExpansionDiagnostic.system` is replaced by `ExpansionDiagnostic.path`.** The field
15
+ used to carry the code system or value set URI the concern was about — consumer-supplied text of
16
+ unbounded length on a diagnostic surface. It now carries a value-free structural locus into your
17
+ own resource, built from integers and the engine's own field names: `"compose.include[2]"`,
18
+ `"compose.include[0].valueSet[1]"`, `"expansion"`. A diagnostic raised while expanding a
19
+ referenced value set is prefixed with the reference that reached it. This is strictly more precise
20
+ than the URI, which cannot distinguish two `include` components naming the same system. `expand`
21
+ and `validateCodeInValueSet` build the path identically, so a locus means the same thing in both;
22
+ they do **not** always emit the same _set_ of diagnostics, because membership can decide an answer
23
+ without evaluating a part expansion still has to report. Reading `d.system` is now a type error.
24
+ The rationale, and the other three sites, are under **Fixed** below.
25
+
12
26
  ### Fixed
13
27
 
28
+ - **A `UnitNode` you assembled yourself could change how `reduce` and `ucumEqual` answer for a unit
29
+ parsed out of the bundled UCUM table, for the rest of the process**
30
+ (`TERMINOLOGY-ATOMMEMO-POISONING`). `reduce` is exported and takes a caller-built node, and
31
+ nothing checks that the atoms on it came from the bundled table. Its per-atom memo, and the guard
32
+ against a definition that leads back to itself, were keyed on `atom.code` — a **string** — so an
33
+ atom you built wrote under the key of whichever shipped atom happened to share its code, and every
34
+ later reduction of that shipped atom read what yours had left.
35
+
36
+ Measured on `0.0.5`: one `reduce()` over a value-less atom coded `L`, as the first touch of `L` in
37
+ the process, made `ucumEqual("L", "1")` and `ucumEqual("mg/L", "mg")` both answer `true` where the
38
+ same calls without it answered `false`, and dropped `mmol/L` from `6.02214076e23 × m-3` to a
39
+ dimensionless `6.02214076e20` — **a concentration comparing equal to a mass**, from an engine
40
+ whose contract is never to fabricate. Two further readings on the same build: an atom coded `N`
41
+ and defined as `N` left the cycle guard armed, so every later `ucumEqual("N", "kg.m/s2")` threw;
42
+ and the value returned for a term with no factors was the engine's own module-global dimensionless
43
+ object, so mutating a result you had been handed turned `kg.m/s2` into `999000 × m2.g.s-2` and
44
+ made `ucumEqual("N", "kg.m/s2")` answer `false`.
45
+
46
+ Four changes:
47
+ - The memo and the cycle guard key on the **atom object**, in a `WeakMap` / `WeakSet`. The atoms
48
+ `parseUcum` resolves are the unit table's cached singletons, so a parsed expression still gets
49
+ one memo entry per atom; an atom you built gets its own entry and is collected with it. Measured
50
+ warm over ten mixed expressions, best of seven trials of 2,000,000 `reduce` calls, on two
51
+ different machines: identity-keyed came in at or ahead of string-keyed both times. Read that as
52
+ no measured cost rather than as a speed-up; the absolute figures are machine- and load-dependent
53
+ and are deliberately not quoted as a pair.
54
+ - An atom carrying no linear definition, and which is neither a base nor an arbitrary unit, is now
55
+ **refused** with `UCUM atom has no linear definition` rather than answered — and memoized — as
56
+ dimensionless. No expression `parseUcum` accepts reaches this, though **not** because every atom
57
+ in the bundled table has a linear definition: 21 of its 312 atoms, the special units, have none.
58
+ It is that an expression containing a special unit short-circuits to the opaque `special` form
59
+ before linear reduction, base and arbitrary units are answered directly, and every remaining atom
60
+ resolves. A node you assemble can reach it — including by defining an atom in terms of a special
61
+ unit such as `Cel`, which lands on the table's own `Cel`.
62
+ - The cycle guard releases its atom in a `finally`, so a throw from inside the recursion no longer
63
+ wedges that atom for the rest of the process.
64
+ - `reduce` no longer returns the module-global dimensionless object. What you get back is per-call
65
+ state, and mutating it cannot reach the engine.
66
+
67
+ Keying on identity was chosen over validating that each atom came from the bundled table: it needs
68
+ no provenance check on the hot path and adds no failure mode for real input, and it is the
69
+ narrower change. The cost is that an atom you built is answered on its own terms only — the
70
+ intended contract, not a regression. **No unit obtained from `parseUcum` changes its answer**: the
71
+ 530-case UCUM conformance suite and the rest of the suite are unchanged and green, and the new
72
+ refusals are reachable only through a node you assembled. **The claim rests on one property, and
73
+ it is the one this repo asserts: every atom in the bundled table reduces, and none of them reaches
74
+ a refusal** (`test/ucum/reduce-memo.test.ts`, last case, over all 312). That is enough to cover
75
+ every expression `parseUcum` accepts rather than sampling them — an expression containing a
76
+ special unit short-circuits before linear reduction, linear reduction is compositional over atoms,
77
+ and the table's own definitions close over that same set, so once every atom answers there is
78
+ nothing left for a bigger corpus to vary. `test/ucum/functional.test.ts` separately drives the
79
+ vendored UCUM functional-test suite's unit strings through `reduce`.
80
+ **Do not restate this as more evidence than that.** Three drafts of this paragraph each claimed
81
+ more than the tree holds — two cited generated-corpus sizes that were not reproducible from their
82
+ own descriptions, and the third attributed a three-clause sweep to a file that pins one clause of
83
+ it. A before/after differential is a two-tree measurement and **no in-tree test can pin one**, by
84
+ construction; do not add a guard to make a sentence here true, shrink the sentence. The three
85
+ guards are now covered by tests
86
+ rather than by a `/* v8 ignore */` block, and each message is pinned by whole-message equality
87
+ rather than by a substring match.
88
+
89
+ - **Consumer-supplied strings reached `TerminologyError.message`, `err.stack` and an
90
+ `ExpansionDiagnostic`, contradicting this package's own "value-free by construction" claim**
91
+ (`PHI-WARNING-MESSAGE-LEAK`). Three sites, found by binding
92
+ `assertNoDiagnosticPhiLeak` from `@cosyte/test-utils@0.0.2` to a 52-slot table covering the
93
+ consumer-controlled positions across the readers and loaders that this repo has identified — which
94
+ is not a proof that none was missed, as the fourth site below shows; **14 of the 52 slots were red on the
95
+ base commit** — 4 CSV column-config slots, 1 `invertGem` slot, 4 in `expand.ts` and 5 in
96
+ `validate.ts` — and the table was run there first for exactly that reason. (An earlier draft of
97
+ this entry said 9. That was the measurement taken before the slot table was extended to the
98
+ **second copy** of the `src/valueset/` diagnostic factories, and it was never re-taken; the five
99
+ `validate.ts` slots are the difference. Re-measure after you widen a table.)
100
+ 1. `csv.ts` `requireHeader` interpolated the caller's configured column name into the
101
+ `TERM_CODESYSTEM_MALFORMED` message. Measured against the published `0.0.5`: a 1,000,000-byte
102
+ `columns.code` produced a 1,000,063-byte `err.message`, and the same bytes landed in
103
+ `err.stack`. It now reads the message out of a frozen `MISSING_COLUMN_MESSAGE` table keyed on
104
+ the column's **role** (`code` / `display` / `status` / `property`) and takes no value
105
+ parameter. That is the `astm`/`transform` shape the ecosystem audit recorded for the designs
106
+ that do not leak, and it is what this factory now is — a property of **this** factory, not a
107
+ rule this package holds everywhere. Several factories here do take `string` parameters and are
108
+ safe because every argument reaching them is engine-owned; they are named below, and reading
109
+ the no-value-parameter shape as a package-wide rule is what made three drafts of this entry
110
+ wrong.
111
+ 2. `invertGem` interpolated `GemMap.direction` into the `TERM_MAP_NOT_INVERTIBLE` message.
112
+ `GemDirection` is a two-value union, but nothing validates it on load and a JavaScript caller
113
+ can pass anything, so the echo was unbounded in practice. Now a closed-set `Map` lookup with a
114
+ direction-free fallback; the direction is kept where it is one of the two authored values,
115
+ because it tells the caller which file to load instead.
116
+ 3. `ExpansionDiagnostic.system` carried the caller's `include.system`, referenced `valueSet` URL,
117
+ or `ValueSet.url` verbatim. **This site is not in the 2026-07-30 ecosystem audit, which
118
+ recorded `terminology` as "one site, a CSV column name".** It is the only one of the three
119
+ that is _document_-derived rather than caller-configuration. **Breaking:** the field is
120
+ replaced by `path`, a value-free index locus (`compose.include[2]`,
121
+ `compose.include[0].valueSet[1]`, `expansion`), built the same way in both `expand.ts` and
122
+ `validate.ts` so a locus means the same thing in each, and prefixed with the reference that
123
+ reached it when the diagnostic comes from a referenced value set. The two do **not** always
124
+ emit the same _set_ of diagnostics and the changeset no longer says they do: membership
125
+ short-circuits on a system mismatch and on an exclude that cannot change a decided verdict,
126
+ where expansion still has to report the part it could not compute. Not a truncation: an index path is
127
+ strictly more precise than the URI, which cannot distinguish two `include`s over one system.
128
+
129
+ **Severity is medium-low and stays medium-low.** Sites 1 and 2 echo the caller's own
130
+ configuration, not a patient's document; site 3 echoes a canonical URI out of reference content.
131
+ The engine's genuinely patient-derived inputs are its _query_ values (a code off a parsed
132
+ message, a unit off an observation, an NDC off a prescription, the `PatientContext` age/gender a
133
+ complex map branches on), and **no query value reaches a message or a stack anywhere**: the
134
+ engine throws only on an unusable source, never on a query. That is now asserted rather than
135
+ argued.
136
+
137
+ **A misfiled result field, corrected in the same docblock and shipped in `dist/index.d.ts`:
138
+ `MapProvenance.sourceSystem`.** A draft of the "which result fields carry verbatim text" list put
139
+ the whole of `MapProvenance` under _your loaded artifact_ — "reference data, not anything a
140
+ patient supplied". That is true of `.targetSystem`, `.conceptMapUrl` and `.conceptMapVersion`
141
+ (`group.target`, `map.url`, `map.version`) and **false of `.sourceSystem`**, which `translate`
142
+ fills from the `system` on the `Coding` **you** passed it, verbatim and unbounded, falling back to
143
+ the map's `group.source` only when the coding carries none. It is a query echo and is now listed
144
+ as one, so a reader applies the log-it-only-if-you-would-log-the-code rule to it. This is a
145
+ classification fix in prose: the value has always been the caller's own, it is not on a
146
+ diagnostic-message surface, and **nothing about the runtime behaviour changed** — bounding it is
147
+ not attempted here, and would be the same fabricate-a-miss trade-off as bounding any other echo.
148
+
149
+ **The `@param` contract this broke** is `TerminologyError`'s own: _"a **value-free** structural
150
+ description (path + fault; never an input value)"_. It now also tells a caller why, since the
151
+ string reaches `err.stack`.
152
+
153
+ **The "Value-free by construction" claim in `src/common/diagnostics.ts` was checked before being
154
+ rewritten, and it was false** on two counts: the two messages above echoed arbitrary input, and
155
+ "at most a code + system + version" described the leaking `ExpansionDiagnostic.system` as though
156
+ it were the safety property. The new wording states the mechanical fact — **every string a message
157
+ is assembled from is owned by the engine**: a literal, a frozen-table entry, or a locus this
158
+ package built (a `line`, a column count, an index path of integers and FHIR field names) — and, in
159
+ the same place, scopes it: it covers **diagnostics**, not **results**, which carry the caller's
160
+ values by design. A first draft of that replacement said instead that "no message is built from a
161
+ value parameter" and that every message is "a literal or a frozen-table entry". **That was false as
162
+ written**, of factories it did not touch: `malformed(path, fault)` in `conceptmap/load.ts` and
163
+ `valueset/load.ts`, and `cannotExpand` / `truncated` / `underPath` in `valueset/` — where
164
+ `expand.ts` and `validate.ts` each carry their own copy — all of which do interpolate
165
+ `string` parameters. **An earlier draft of this paragraph said "four", and the tree has seven**;
166
+ a count in prose is the thing that goes stale, so it is derived now, not written down.
167
+ The code is fine — every argument reaching them is engine-owned, each `fault`
168
+ a literal at the call site and each `path` built from indices — but the sentence was stronger than
169
+ the code, which is precisely the failure this entry exists to end. It now describes the arguments,
170
+ not the signatures.
171
+
172
+ **The mechanism is now stated as a general rule in exactly one place — the
173
+ `src/common/diagnostics.ts` docblock.** `README.md`, `docs-content/troubleshooting.md` and
174
+ `docs-content/concepts-archetype.md` carried the old "code + system + version" sentence, and the
175
+ `test/phi/diagnostic-surface.test.ts` header (new in this slice) carried the same doctrine in a
176
+ later wording; all four now state only the **consumer-facing property** — nothing you configured
177
+ and nothing your release or resource contained reaches a `message`, `detail`, `reason` or
178
+ `err.stack`, at any length — and none restates the general rule. An earlier draft of this slice
179
+ moved all four to a _restatement_ of the mechanism instead, in the wording already found false of
180
+ `malformed` / `cannotExpand` / `truncated`, and this entry claimed they had moved with the fix
181
+ when what they had moved to was the superseded draft. No gate in this repo can grade an English
182
+ sentence, which is the argument for one copy of the rule rather than four. **Statements scoped to
183
+ a single factory are not copies of the rule and deliberately stay put** — `csv.ts` still says its
184
+ own `MISSING_COLUMN_MESSAGE` lookup takes no value parameter, and `TerminologyError`'s `@param`
185
+ still tells a caller what may go into the string it is handed. The `diagnostics` guidance in
186
+ `troubleshooting.md`, which told readers to read a diagnostic for "which code system was missing",
187
+ points at `path`.
188
+
189
+ **What the gate proves is narrow, deliberately:** for each declared slot, no verbatim echo of
190
+ four or more bytes of the planted value on any swept surface, and the slot provably reached the
191
+ code it names. It does not prove the absence of a re-encoded echo, an echo under four bytes, or a
192
+ leak through a slot nobody declared. Two limits are written into the file rather than implied
193
+ away. First, `expectCode` shows the branch ran, not that the _marker_ ran it: about half the slots
194
+ are **model-only** and pair the marker with a second marker-free malformed element so a diagnostic
195
+ exists to sweep, and for those it is the sweep of **those diagnostics** that carries the proof. (An
196
+ earlier draft said "the whole rendered result". The runner sweeps exactly what the two selectors
197
+ return plus the thrown value, so for the `CodeSystem.url` slot the `url` itself is never swept —
198
+ correctly, since it is payload, but not swept.) Second, `getModelIdentifiers` returns the model's
199
+ real loci — a `RxNormLoadWarning.file`
200
+ token, an `ExpansionDiagnostic.path` — which are also swept as part of their diagnostic, so it is
201
+ redundant rather than load-bearing; it exists so the classification of every name-like string on
202
+ every exported model type is executed and reviewable instead of collapsing to a `[]` a reader has
203
+ to trust. `Property.code` and `RxNormEdge.predicate` are classified as payload on a checkable
204
+ test: an HL7 v2 `segment.type` is spec-bounded to three characters, so bounding it discards
205
+ nothing, while a FHIR `code` carries no `maxLength` and RxNorm's `RELA` is an open vocabulary, and
206
+ both are the keys the engine and the caller match on, so truncating either turns a hit into a
207
+ miss. The residual is
208
+ named: that protects the lookup, not a future consumer who builds a locus out of one.
209
+ `@cosyte/cli` is today the only package in the ecosystem that depends on this one, and it uses
210
+ `loadConceptMap` + `translate` only.
211
+
212
+ **A FOURTH site, found by the refuter after the three above: `reduce` echoed a forged atom's
213
+ `code` into a plain `Error.message` and `err.stack`** — a second ~1 MB leak. `reduce` is exported
214
+ and takes a caller-built
215
+ `UnitNode`, and nothing checks that the atoms on it came from the vendored UCUM table, so a
216
+ forged atom whose `value.unit` does not parse reaches the throw. Measured on `0.0.5`: a
217
+ 1,000,000-byte atom code produced a **1,000,042-byte** `Error.message`, with the same bytes in
218
+ `err.stack`. Every `Error` message `reduce` can throw is a literal — two of them at that point,
219
+ three after the memo fix recorded at the top of this section. The site had been marked `/* v8 ignore */` as
220
+ "unreachable with the shipped data", and the throw was moved outside that block, with
221
+ `test/ucum/reduce.test.ts` pinning the message and the stack. **The two branches still inside the
222
+ block were not unreachable either, and the comment stopped saying they were.** A draft of that slice
223
+ relabelled them "genuinely unreachable through the public API"; they were not, because `inProgress`
224
+ and `atomMemo` keyed on the atom's `code` string and nothing checked that an atom came from the
225
+ vendored table, so a caller-forged atom whose code collided with a shipped one re-entered the
226
+ cyclic guard (measured: `reduce` over a forged `mol` defined as `mol` threw it) and a forged atom
227
+ with no `value` fell through the second, which threw nothing — it memoized dimensionless and
228
+ returned. The "with the shipped data" scope was the defensible one and was restored, stated
229
+ as a coverage exclusion over a corrupt-table guard rather than as an assertion of unreachability.
230
+ Neither branch leaked — the one message inside the block was a literal — so nothing was deferred **on
231
+ the diagnostic surface**. What was deferred is not merely that the branches were reachable: that
232
+ second branch wrote the forged atom's `code` into the module-global `atomMemo` before returning,
233
+ so one `reduce()` over a forged, value-less atom whose code collided with a shipped one poisoned
234
+ every later reduction of that atom in the process — enough to make `ucumEqual("mg/L", "mg")`
235
+ answer `true`. That is a **never-fabricate defect, not a diagnostic-surface one**, it was
236
+ byte-identical on `0.0.5`, and it was filed as its own item rather than folded in here. **It is
237
+ fixed in this same unreleased window — see the memo-poisoning entry at the top of this section —
238
+ and the `/* v8 ignore */` block is gone with it.** It is **not** in the slot table and
239
+ cannot be: it throws an un-coded `Error`, so it can carry no `expectCode`, and every slot in
240
+ that table names one. The 2026-07-30 ecosystem audit recorded this package as "one site"; with
241
+ `ExpansionDiagnostic.system` and this, it was **three**.
242
+
14
243
  - **`LICENSE` was unqualified MIT over a tree that also distributes third-party material**
15
244
  (`TERMINOLOGY-LICENSE-THIRD-PARTY`, residuals 1 and 2 of `TERMINOLOGY-RESIDUALS`). The README had
16
245
  already grown a "What is bundled" carve-out; the licence file had not, so the two consumer-facing
@@ -207,6 +436,20 @@ has_ingredient IN`). RxNorm authors no such relationship in any release: the ing
207
436
  the matcher can see a real autolink, so a green result cannot come from a pattern that matches
208
437
  nothing. It reads bytes directly rather than shelling out to `grep`, which this repository has
209
438
  already been burned by. Scoped to `docs-content/` only, for the reason above.
439
+ - **The README opens with the Cosyte mark, which follows the reader's color scheme.** A `<picture>`
440
+ block above the H1 offers a dark-ground tile behind a `prefers-color-scheme: dark` media query and
441
+ carries the light-ground tile as the inner `<img>`, so on a renderer that honors the switch the
442
+ mark sits on a ground that matches the page it is read on. This README carried no image at all
443
+ before, so the block is purely additive: the `# @cosyte/terminology` heading, the blockquote
444
+ tagline and every section below are unchanged, and because the lockup reads "Cosyte" while the
445
+ heading reads `@cosyte/terminology`, nothing is duplicated. The failure mode is safe: a renderer
446
+ that strips `<source>` renders the inner `<img>`, so the worst case is a light-ground mark on a
447
+ dark page, never a missing image. On the npm package page the `<img>` is hoisted out of its
448
+ `<picture>` by the anchor wrapper, so the light cut renders there, which is the correct one
449
+ because npmjs.com has no dark mode. The alt text describes the artwork rather than the package,
450
+ since it is what a screen reader reads out and what a reader gets when the image fails. Both tile
451
+ URLs were rechecked before push and returned `200 image/png`. No behaviour, API surface or bundled
452
+ content changed.
210
453
 
211
454
  ### Changed
212
455
 
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
3
+ <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ </picture>
5
+
1
6
  # @cosyte/terminology
2
7
 
3
8
  > A **zero-dependency terminology engine** for US healthcare code systems — FHIR-shaped, BYO-data,
@@ -194,8 +199,14 @@ _names_, listed under [What is bundled](#what-is-bundled).
194
199
  **are** bundled are named, with their copyright, under [What is bundled](#what-is-bundled).
195
200
  - **Liberal load, conservative assertion.** Malformed input degrades to a typed diagnostic; a 1:many
196
201
  mapping returns the full candidate set, never collapsed to one.
197
- - **Value-free diagnostics.** A diagnostic carries a code + system + version, never patient context —
198
- a code _in patient context_ can be PHI.
202
+ - **Value-free diagnostic messages.** Nothing you configured and nothing your release or resource
203
+ contained reaches a `message`, `detail`, `reason` or `err.stack` — not a column name, not a
204
+ canonical URI, not a unit atom — at any length. What sits beside a message is a locus: a line
205
+ number, a column count, an index path into your own resource (`compose.include[2]`), or a fixed
206
+ token naming which file of a release it came from. The
207
+ **objects** are a different matter: a typed `unknown`/`unmapped` outcome names the code, unit or
208
+ NDC you asked about, so log the `code` and the locus rather than the whole result — a code _in
209
+ patient context_ can be PHI.
199
210
  - **Zero runtime dependencies. Dual ESM + CJS.** Node stdlib only; built with `tsup`, validated with
200
211
  `attw`. Immutable by construction (every returned value is deep-frozen).
201
212
 
package/dist/index.cjs CHANGED
@@ -199,6 +199,9 @@ var TerminologyError = class _TerminologyError extends Error {
199
199
  /**
200
200
  * @param code - The stable {@link FatalCode} describing the fault.
201
201
  * @param message - A **value-free** structural description (path + fault; never an input value).
202
+ * Every piece of it must be engine-owned — a literal, a frozen-table entry, or a locus this
203
+ * package built. This string reaches `err.stack`, so interpolating a caller-supplied or
204
+ * document-derived value into it is how a diagnostic becomes a leak.
202
205
  */
203
206
  constructor(code, message) {
204
207
  super(message);
@@ -652,13 +655,16 @@ function parseCsvRows(content) {
652
655
  function parseCsv(content) {
653
656
  return parseCsvRows(content).rows;
654
657
  }
655
- function requireHeader(header, name) {
658
+ var MISSING_COLUMN_MESSAGE = Object.freeze({
659
+ code: "CSV source: the configured 'code' column is not present in the header row",
660
+ display: "CSV source: the configured 'display' column is not present in the header row",
661
+ status: "CSV source: the configured 'status' column is not present in the header row",
662
+ property: "CSV source: a configured property column is not present in the header row"
663
+ });
664
+ function requireHeader(header, name, role) {
656
665
  const idx = header.indexOf(name);
657
666
  if (idx < 0) {
658
- throw new TerminologyError(
659
- FATAL_CODES.TERM_CODESYSTEM_MALFORMED,
660
- `CSV source: required column '${name}' is not present in the header row`
661
- );
667
+ throw new TerminologyError(FATAL_CODES.TERM_CODESYSTEM_MALFORMED, MISSING_COLUMN_MESSAGE[role]);
662
668
  }
663
669
  return idx;
664
670
  }
@@ -682,12 +688,12 @@ function parseCsvSource(source) {
682
688
  })
683
689
  );
684
690
  }
685
- const codeIdx = requireHeader(header, source.columns.code);
686
- const displayIdx = requireHeader(header, source.columns.display);
687
- const statusIdx = source.columns.status !== void 0 ? requireHeader(header, source.columns.status) : void 0;
691
+ const codeIdx = requireHeader(header, source.columns.code, "code");
692
+ const displayIdx = requireHeader(header, source.columns.display, "display");
693
+ const statusIdx = source.columns.status !== void 0 ? requireHeader(header, source.columns.status, "status") : void 0;
688
694
  const propIdx = (source.columns.properties ?? []).map((n) => ({
689
695
  name: n,
690
- index: requireHeader(header, n)
696
+ index: requireHeader(header, n, "property")
691
697
  }));
692
698
  const needCols = Math.max(codeIdx, displayIdx, statusIdx ?? 0, ...propIdx.map((p) => p.index)) + 1;
693
699
  const concepts = /* @__PURE__ */ new Map();
@@ -1291,48 +1297,54 @@ function makeCoding(system, code, display, version) {
1291
1297
  if (display !== void 0) init.display = display;
1292
1298
  return coding(init);
1293
1299
  }
1294
- function cannotExpand(detail, system) {
1300
+ function cannotExpand(detail, path) {
1295
1301
  const d = { code: "TERM_VALUESET_CANNOT_EXPAND", detail };
1296
- if (system !== void 0) d.system = system;
1302
+ if (path !== void 0) d.path = path;
1297
1303
  return Object.freeze(d);
1298
1304
  }
1299
- function truncated(detail, system) {
1305
+ function truncated(detail, path) {
1300
1306
  const d = { code: "TERM_VALUESET_EXPANSION_TRUNCATED", detail };
1301
- if (system !== void 0) d.system = system;
1307
+ d.path = path;
1302
1308
  return Object.freeze(d);
1303
1309
  }
1310
+ function underPath(d, prefix) {
1311
+ const out = { code: d.code, detail: d.detail };
1312
+ out.path = d.path === void 0 ? prefix : `${prefix}/${d.path}`;
1313
+ return Object.freeze(out);
1314
+ }
1304
1315
  function intersectInto(into, other) {
1305
1316
  for (const k of into.keys()) if (!other.has(k)) into.delete(k);
1306
1317
  return into;
1307
1318
  }
1308
- function expandReferencedValueSets(urls, ctx, visited) {
1319
+ function expandReferencedValueSets(urls, ctx, visited, path) {
1309
1320
  const diagnostics = [];
1310
1321
  let complete = true;
1311
1322
  let acc = null;
1312
- for (const url of urls) {
1323
+ for (const [i, url] of urls.entries()) {
1324
+ const refPath = `${path}.valueSet[${String(i)}]`;
1313
1325
  if (visited.has(url)) {
1314
- diagnostics.push(cannotExpand("cyclic value set reference", url));
1326
+ diagnostics.push(cannotExpand("cyclic value set reference", refPath));
1315
1327
  complete = false;
1316
1328
  acc = /* @__PURE__ */ new Map();
1317
1329
  continue;
1318
1330
  }
1319
1331
  const vs = ctx.valueSets?.get(url);
1320
1332
  if (vs === void 0) {
1321
- diagnostics.push(cannotExpand("referenced value set not supplied", url));
1333
+ diagnostics.push(cannotExpand("referenced value set not supplied", refPath));
1322
1334
  complete = false;
1323
1335
  acc = /* @__PURE__ */ new Map();
1324
1336
  continue;
1325
1337
  }
1326
1338
  const exp = expandInternal(vs, ctx, /* @__PURE__ */ new Set([...visited, url]));
1327
1339
  if (!exp.complete) complete = false;
1328
- for (const d of exp.diagnostics) diagnostics.push(d);
1340
+ for (const d of exp.diagnostics) diagnostics.push(underPath(d, refPath));
1329
1341
  const members = /* @__PURE__ */ new Map();
1330
1342
  for (const c of exp.contains) members.set(codingKey(c.system, c.code), c);
1331
1343
  acc = acc === null ? members : intersectInto(acc, members);
1332
1344
  }
1333
1345
  return { members: acc ?? /* @__PURE__ */ new Map(), complete, diagnostics };
1334
1346
  }
1335
- function expandComponent(component, ctx, visited) {
1347
+ function expandComponent(component, ctx, visited, path) {
1336
1348
  const diagnostics = [];
1337
1349
  let complete = true;
1338
1350
  const { system, version, concept, filter, valueSet: valueSet2 } = component;
@@ -1347,18 +1359,18 @@ function expandComponent(component, ctx, visited) {
1347
1359
  }
1348
1360
  } else if (hasFilter || system !== void 0 && !hasVs) {
1349
1361
  if (system === void 0) {
1350
- diagnostics.push(cannotExpand("intensional filter without a code system 'system'"));
1362
+ diagnostics.push(cannotExpand("intensional filter without a code system 'system'", path));
1351
1363
  return { members: /* @__PURE__ */ new Map(), complete: false, diagnostics };
1352
1364
  }
1353
1365
  const cs = ctx.codeSystems?.get(system);
1354
1366
  if (cs === void 0) {
1355
- diagnostics.push(cannotExpand("code system not supplied for intensional include", system));
1367
+ diagnostics.push(cannotExpand("code system not supplied for intensional include", path));
1356
1368
  return { members: /* @__PURE__ */ new Map(), complete: false, diagnostics };
1357
1369
  }
1358
1370
  if (hasFilter) {
1359
1371
  const unsupported = unsupportedOps(filter);
1360
1372
  if (unsupported.length > 0) {
1361
- diagnostics.push(cannotExpand("unsupported filter operator", system));
1373
+ diagnostics.push(cannotExpand("unsupported filter operator", path));
1362
1374
  return { members: /* @__PURE__ */ new Map(), complete: false, diagnostics };
1363
1375
  }
1364
1376
  const sub = buildSubsumption(cs);
@@ -1382,7 +1394,7 @@ function expandComponent(component, ctx, visited) {
1382
1394
  }
1383
1395
  }
1384
1396
  if (hasVs) {
1385
- const refs = expandReferencedValueSets(valueSet2, ctx, visited);
1397
+ const refs = expandReferencedValueSets(valueSet2, ctx, visited, path);
1386
1398
  for (const d of refs.diagnostics) diagnostics.push(d);
1387
1399
  if (!refs.complete) complete = false;
1388
1400
  base = base === null ? refs.members : intersectInto(base, refs.members);
@@ -1407,7 +1419,7 @@ function expandInternal(vs, ctx, visited) {
1407
1419
  if (vs.expansion.truncated) {
1408
1420
  complete = false;
1409
1421
  diagnostics.push(
1410
- truncated("pre-computed expansion is incomplete (truncated or too-costly)", vs.url)
1422
+ truncated("pre-computed expansion is incomplete (truncated or too-costly)", "expansion")
1411
1423
  );
1412
1424
  }
1413
1425
  const out = {
@@ -1422,14 +1434,14 @@ function expandInternal(vs, ctx, visited) {
1422
1434
  const included = /* @__PURE__ */ new Map();
1423
1435
  const diagnostics = [];
1424
1436
  let complete = true;
1425
- for (const inc of vs.compose.include) {
1426
- const r = expandComponent(inc, ctx, visited);
1437
+ for (const [i, inc] of vs.compose.include.entries()) {
1438
+ const r = expandComponent(inc, ctx, visited, `compose.include[${String(i)}]`);
1427
1439
  if (!r.complete) complete = false;
1428
1440
  for (const d of r.diagnostics) diagnostics.push(d);
1429
1441
  for (const [k, c] of r.members) if (!included.has(k)) included.set(k, c);
1430
1442
  }
1431
- for (const exc of vs.compose.exclude) {
1432
- const r = expandComponent(exc, ctx, visited);
1443
+ for (const [i, exc] of vs.compose.exclude.entries()) {
1444
+ const r = expandComponent(exc, ctx, visited, `compose.exclude[${String(i)}]`);
1433
1445
  for (const d of r.diagnostics) diagnostics.push(d);
1434
1446
  for (const k of r.members.keys()) included.delete(k);
1435
1447
  if (!r.complete) {
@@ -1456,12 +1468,17 @@ function expand(vs, ctx = {}) {
1456
1468
  }
1457
1469
 
1458
1470
  // src/valueset/validate.ts
1459
- function cannotExpand2(detail, system) {
1471
+ function cannotExpand2(detail, path) {
1460
1472
  const d = { code: "TERM_VALUESET_CANNOT_EXPAND", detail };
1461
- if (system !== void 0) d.system = system;
1473
+ if (path !== void 0) d.path = path;
1462
1474
  return Object.freeze(d);
1463
1475
  }
1464
- function matchComponent(target, component, ctx, visited) {
1476
+ function underPath2(d, prefix) {
1477
+ const out = { code: d.code, detail: d.detail };
1478
+ out.path = d.path === void 0 ? prefix : `${prefix}/${d.path}`;
1479
+ return Object.freeze(out);
1480
+ }
1481
+ function matchComponent(target, component, ctx, visited, path) {
1465
1482
  const diagnostics = [];
1466
1483
  const { system, concept, filter, valueSet: valueSet2 } = component;
1467
1484
  const hasConcept = concept !== void 0;
@@ -1475,18 +1492,18 @@ function matchComponent(target, component, ctx, visited) {
1475
1492
  baseMatched = concept.some((c) => c.code === target.code);
1476
1493
  } else if (hasFilter || system !== void 0 && !hasVs) {
1477
1494
  if (system === void 0) {
1478
- diagnostics.push(cannotExpand2("intensional filter without a code system 'system'"));
1495
+ diagnostics.push(cannotExpand2("intensional filter without a code system 'system'", path));
1479
1496
  baseMatched = false;
1480
1497
  baseComplete = false;
1481
1498
  } else {
1482
1499
  const cs = ctx.codeSystems?.get(system);
1483
1500
  if (cs === void 0) {
1484
- diagnostics.push(cannotExpand2("code system not supplied for intensional include", system));
1501
+ diagnostics.push(cannotExpand2("code system not supplied for intensional include", path));
1485
1502
  baseMatched = false;
1486
1503
  baseComplete = false;
1487
1504
  } else if (hasFilter) {
1488
1505
  if (unsupportedOps(filter).length > 0) {
1489
- diagnostics.push(cannotExpand2("unsupported filter operator", system));
1506
+ diagnostics.push(cannotExpand2("unsupported filter operator", path));
1490
1507
  baseMatched = false;
1491
1508
  baseComplete = false;
1492
1509
  } else {
@@ -1501,21 +1518,22 @@ function matchComponent(target, component, ctx, visited) {
1501
1518
  let refDefiniteFalse = false;
1502
1519
  let refUndetermined = false;
1503
1520
  if (hasVs) {
1504
- for (const url of valueSet2) {
1521
+ for (const [i, url] of valueSet2.entries()) {
1522
+ const refPath = `${path}.valueSet[${String(i)}]`;
1505
1523
  if (visited.has(url)) {
1506
- diagnostics.push(cannotExpand2("cyclic value set reference", url));
1524
+ diagnostics.push(cannotExpand2("cyclic value set reference", refPath));
1507
1525
  refUndetermined = true;
1508
1526
  continue;
1509
1527
  }
1510
1528
  const vs = ctx.valueSets?.get(url);
1511
1529
  if (vs === void 0) {
1512
- diagnostics.push(cannotExpand2("referenced value set not supplied", url));
1530
+ diagnostics.push(cannotExpand2("referenced value set not supplied", refPath));
1513
1531
  refUndetermined = true;
1514
1532
  continue;
1515
1533
  }
1516
1534
  const m = validateInternal(target, vs, ctx, /* @__PURE__ */ new Set([...visited, url]));
1517
1535
  if (m.undetermined) {
1518
- for (const d of m.diagnostics) diagnostics.push(d);
1536
+ for (const d of m.diagnostics) diagnostics.push(underPath2(d, refPath));
1519
1537
  refUndetermined = true;
1520
1538
  } else if (!m.result) {
1521
1539
  refDefiniteFalse = true;
@@ -1552,7 +1570,7 @@ function validateInternal(target, vs, ctx, visited) {
1552
1570
  if (found) return decided(true, target);
1553
1571
  if (vs.expansion.truncated) {
1554
1572
  return undetermined(target, [
1555
- cannotExpand2("pre-computed expansion is incomplete (truncated or too-costly)", vs.url)
1573
+ cannotExpand2("pre-computed expansion is incomplete (truncated or too-costly)", "expansion")
1556
1574
  ]);
1557
1575
  }
1558
1576
  return decided(false, target);
@@ -1561,16 +1579,16 @@ function validateInternal(target, vs, ctx, visited) {
1561
1579
  const diagnostics = [];
1562
1580
  let includedDefinite = false;
1563
1581
  let anyIncludeUndetermined = false;
1564
- for (const inc of vs.compose.include) {
1565
- const m = matchComponent(target, inc, ctx, visited);
1582
+ for (const [i, inc] of vs.compose.include.entries()) {
1583
+ const m = matchComponent(target, inc, ctx, visited, `compose.include[${String(i)}]`);
1566
1584
  for (const d of m.diagnostics) diagnostics.push(d);
1567
1585
  if (m.complete && m.matched) includedDefinite = true;
1568
1586
  if (!m.complete) anyIncludeUndetermined = true;
1569
1587
  }
1570
1588
  let excludedDefinite = false;
1571
1589
  let anyExcludeUndetermined = false;
1572
- for (const exc of vs.compose.exclude) {
1573
- const m = matchComponent(target, exc, ctx, visited);
1590
+ for (const [i, exc] of vs.compose.exclude.entries()) {
1591
+ const m = matchComponent(target, exc, ctx, visited, `compose.exclude[${String(i)}]`);
1574
1592
  for (const d of m.diagnostics) diagnostics.push(d);
1575
1593
  if (m.complete && m.matched) excludedDefinite = true;
1576
1594
  if (!m.complete) anyExcludeUndetermined = true;
@@ -3893,32 +3911,37 @@ function power(r, p) {
3893
3911
  }
3894
3912
  return { kind: "linear", factor: Math.pow(r.factor, p), dims };
3895
3913
  }
3896
- var DIMENSIONLESS = { kind: "linear", factor: 1, dims: {} };
3897
- var atomMemo = /* @__PURE__ */ new Map();
3898
- var inProgress = /* @__PURE__ */ new Set();
3914
+ var DIMENSIONLESS = Object.freeze({
3915
+ kind: "linear",
3916
+ factor: 1,
3917
+ dims: Object.freeze({})
3918
+ });
3919
+ var atomMemo = /* @__PURE__ */ new WeakMap();
3920
+ var inProgress = /* @__PURE__ */ new WeakSet();
3899
3921
  function reduceAtomLinear(atom) {
3900
3922
  if (atom.base) return { kind: "linear", factor: 1, dims: { [atom.dim ?? atom.code]: 1 } };
3901
3923
  if (atom.arbitrary) return { kind: "linear", factor: 1, dims: { [`arb:${atom.code}`]: 1 } };
3902
- const cached2 = atomMemo.get(atom.code);
3924
+ const cached2 = atomMemo.get(atom);
3903
3925
  if (cached2) return cached2;
3904
- if (inProgress.has(atom.code)) {
3905
- throw new Error(`cyclic UCUM atom definition for '${atom.code}'`);
3926
+ if (inProgress.has(atom)) {
3927
+ throw new Error("cyclic UCUM atom definition in the unit table");
3906
3928
  }
3907
3929
  if (!atom.value) {
3908
- atomMemo.set(atom.code, DIMENSIONLESS);
3909
- return DIMENSIONLESS;
3930
+ throw new Error("UCUM atom has no linear definition");
3910
3931
  }
3911
- inProgress.add(atom.code);
3912
- const parsed = parseUcum(atom.value.unit);
3913
- if (!parsed.ok) {
3914
- inProgress.delete(atom.code);
3915
- throw new Error(`unparseable UCUM essence definition for '${atom.code}'`);
3932
+ inProgress.add(atom);
3933
+ try {
3934
+ const parsed = parseUcum(atom.value.unit);
3935
+ if (!parsed.ok) {
3936
+ throw new Error("unparseable UCUM essence definition in the unit table");
3937
+ }
3938
+ const reduced = linearReduceTerm(parsed.node);
3939
+ const result = multiply({ kind: "linear", factor: atom.value.factor, dims: {} }, reduced);
3940
+ atomMemo.set(atom, result);
3941
+ return result;
3942
+ } finally {
3943
+ inProgress.delete(atom);
3916
3944
  }
3917
- const reduced = linearReduceTerm(parsed.node);
3918
- const result = multiply({ factor: atom.value.factor, dims: {} }, reduced);
3919
- inProgress.delete(atom.code);
3920
- atomMemo.set(atom.code, result);
3921
- return result;
3922
3945
  }
3923
3946
  function reduceSimpleLinear(node) {
3924
3947
  const base = reduceAtomLinear(node.atom);
@@ -3938,7 +3961,7 @@ function linearReduceComponent(comp) {
3938
3961
  }
3939
3962
  }
3940
3963
  function linearReduceTerm(node) {
3941
- let acc = DIMENSIONLESS;
3964
+ let acc = { kind: "linear", factor: 1, dims: {} };
3942
3965
  for (const { sign, node: comp } of node.factors) {
3943
3966
  acc = multiply(acc, power(linearReduceComponent(comp), sign));
3944
3967
  }
@@ -4120,10 +4143,15 @@ function applyGem(map, source) {
4120
4143
  if (combinations !== void 0) matched.combinations = combinations;
4121
4144
  return Object.freeze(matched);
4122
4145
  }
4146
+ var NOT_INVERTIBLE_MESSAGE = /* @__PURE__ */ new Map([
4147
+ ["9-to-10", "GEM map (direction 9-to-10) cannot be inverted \u2014 load the 10-to-9 GEM file instead"],
4148
+ ["10-to-9", "GEM map (direction 10-to-9) cannot be inverted \u2014 load the 9-to-10 GEM file instead"]
4149
+ ]);
4150
+ var NOT_INVERTIBLE_UNKNOWN_DIRECTION = "GEM map cannot be inverted \u2014 load the reverse GEM file instead";
4123
4151
  function invertGem(map) {
4124
4152
  throw new TerminologyError(
4125
4153
  FATAL_CODES.TERM_MAP_NOT_INVERTIBLE,
4126
- `GEM map (direction ${map.direction}) cannot be inverted \u2014 load the reverse GEM file instead`
4154
+ NOT_INVERTIBLE_MESSAGE.get(map.direction) ?? NOT_INVERTIBLE_UNKNOWN_DIRECTION
4127
4155
  );
4128
4156
  }
4129
4157
 
@@ -4703,7 +4731,7 @@ function approximateMatch(graph, query, options = {}) {
4703
4731
  }
4704
4732
 
4705
4733
  // src/index.ts
4706
- var VERSION = "0.0.4";
4734
+ var VERSION = "0.0.6";
4707
4735
 
4708
4736
  exports.COMPLEX_MAP_SOURCE_SYSTEM = COMPLEX_MAP_SOURCE_SYSTEM;
4709
4737
  exports.DIAGNOSTIC_CODES = DIAGNOSTIC_CODES;