From e5482a26bc1f85d00d31b73877d463a5f99f28fb Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 14 Aug 2026 00:14:32 +0000 Subject: [PATCH 1/2] feat(reverse): emit a narrow FHIR to v2 subset, trigger always required (S0013-transform-advance) Adds the reverse direction, deliberately narrow: `toV2Patient(patient, trigger)` emits a complete `ADT` message carrying a PID, `toV2Observation(observation, trigger)` a complete `ORU` message carrying an OBX. Both return the existing `{ value, issues }` envelope, with an `@cosyte/hl7` message as the value. The trigger is a required argument on both entry points and is never inferred: no FHIR resource carries an HL7 v2 message trigger. Missing, empty or non-string returns TRANSFORM_MISSING_TRIGGER with no builder call at all; a string that is not a bare trigger returns TRANSFORM_VALUE_NOT_REPRESENTABLE rather than being trimmed into something the caller did not ask for. Lossy by construction and not a round-trip. The IG maps v2 to FHIR and publishes no map the other way, so every row here is an inverse, and `invertCodeMap` keeps only the rows exactly one v2 code produces. The many-to-one rows are refused with TRANSFORM_CODE_NOT_INVERTIBLE: gender `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and status `entered-in-error`. The property suite asserts every emitted message parses back under `parseHL7` without a fatal error and carries the trigger verbatim in MSH-9; nothing asserts equality with any original. Seven ISSUE_CODES entries are added, additions only. They are issue codes rather than fatal codes because they are returned, never thrown, which is the structural line the diagnostics module draws between the two registries. The third scoped shape, a Patient + Encounter visit-carrying ADT, is deferred with a dated rationale in documentation/decisions/0003: the vendored parser exports no ADT assembly entry point (measured: zero occurrences in its dist), and hand-assembling that message structure here would invert the tier split ADR 0001 draws. --- .changeset/brave-pandas-shout.md | 13 + CLAUDE.md | 17 +- README.md | 44 +- docs-content/guides-overview.md | 36 ++ docs-content/intro.md | 6 +- docs-content/troubleshooting.md | 13 +- documentation/agent-notes.md | 52 ++- ...rowly-and-defers-the-visit-carrying-adt.md | 75 ++++ src/diagnostics/codes.ts | 37 ++ src/diagnostics/issue.ts | 42 ++ src/index.ts | 24 +- src/messages/observation.ts | 12 +- src/reverse/coding.ts | 225 ++++++++++ src/reverse/message.ts | 209 +++++++++ src/reverse/observation.ts | 423 ++++++++++++++++++ src/reverse/patient.ts | 320 +++++++++++++ src/reverse/read.ts | 154 +++++++ src/reverse/v2.ts | 170 +++++++ test/diagnostics/codes-and-issue.test.ts | 8 + test/reverse/observation.test.ts | 312 +++++++++++++ test/reverse/patient.test.ts | 263 +++++++++++ test/reverse/property.test.ts | 185 ++++++++ vitest.config.ts | 2 +- 23 files changed, 2625 insertions(+), 17 deletions(-) create mode 100644 .changeset/brave-pandas-shout.md create mode 100644 documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md create mode 100644 src/reverse/coding.ts create mode 100644 src/reverse/message.ts create mode 100644 src/reverse/observation.ts create mode 100644 src/reverse/patient.ts create mode 100644 src/reverse/read.ts create mode 100644 src/reverse/v2.ts create mode 100644 test/reverse/observation.test.ts create mode 100644 test/reverse/patient.test.ts create mode 100644 test/reverse/property.test.ts diff --git a/.changeset/brave-pandas-shout.md b/.changeset/brave-pandas-shout.md new file mode 100644 index 0000000..4f06849 --- /dev/null +++ b/.changeset/brave-pandas-shout.md @@ -0,0 +1,13 @@ +--- +"@cosyte/transform": patch +--- + +Add a narrow reverse path, FHIR to HL7 v2: `toV2Patient(patient, trigger)` emits a complete `ADT` message carrying a `PID`, and `toV2Observation(observation, trigger)` a complete `ORU` message carrying an `OBX` (roadmap §Phase 7, shipped for two of the three scoped shapes). + +Each takes the FHIR resource **plus the v2 trigger the message should carry**, and returns the same `{ value, issues }` envelope the forward direction uses, where `value` is a complete `@cosyte/hl7` message. The trigger is required and is never inferred: no FHIR resource carries an HL7 v2 message trigger, so a missing, empty or non-string one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic without calling the builder at all, and a trigger that is not a bare token (whitespace, or a delimiter that would split MSH-9) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE` rather than being trimmed into something else. + +**This direction is lossy by design and is not a round-trip.** The IG maps v2 to FHIR and publishes no map the other way, so every row here is the inverse of a published row, and an inverse is only usable where the forward row is one-to-one. `invertCodeMap` enforces exactly that, and the many-to-one rows are refused with `TRANSFORM_CODE_NOT_INVERTIBLE` rather than resolved to their likeliest source code: `gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and `Observation.status` `entered-in-error`. An element with no v2 field in this map is flagged `TRANSFORM_NO_V2_TARGET`, a value v2 cannot carry unchanged is left out with `TRANSFORM_VALUE_NOT_REPRESENTABLE`, and a coding system with no v2 mnemonic is flagged `TRANSFORM_CODE_SYSTEM_NOT_V2` rather than written under a borrowed table. Nothing asserts that a message transformed to FHIR and back equals the original; the property suite verifies only that every emitted message parses back under `parseHL7` without a fatal error and carries the caller's trigger verbatim in MSH-9. + +Seven issue codes are added (`TRANSFORM_MISSING_TRIGGER`, `TRANSFORM_UNSUPPORTED_RESOURCE`, `TRANSFORM_RESOURCE_MALFORMED`, `TRANSFORM_NO_V2_TARGET`, `TRANSFORM_VALUE_NOT_REPRESENTABLE`, `TRANSFORM_CODE_NOT_INVERTIBLE`, `TRANSFORM_CODE_SYSTEM_NOT_V2`), additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed. They are `ISSUE_CODES` entries because they are returned rather than thrown, which is the structural line between the two registries in this package. + +The third scoped shape, a `Patient` + `Encounter` visit-carrying ADT, is deferred with a dated rationale in `documentation/decisions/0003`: the vendored parser exports no ADT assembly entry point, and hand-assembling that message structure here would invert the tier split ADR 0001 draws. diff --git a/CLAUDE.md b/CLAUDE.md index 0fdbf36..8cf3eaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,21 @@ as a trap is clinical-safety content. ## Status -- **Phases 1–6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM - message graphs, and the IG value-ConceptMap translation layer. Phases **7 (FHIR→v2)** and - **8 (profiles)** and deeper terminology are deferred. Full per-phase inventory: +- **Phases 1-6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM + message graphs, and the IG value-ConceptMap translation layer. Full per-phase inventory: `documentation/agent-notes.md#shipped-phase-history-phases-16`. +- **Phase 7 (FHIR→v2) shipped NARROWLY, and the narrowness is the point**: `toV2Patient` and + `toV2Observation` emit a **complete** v2 message (`ADT^` + PID, `ORU^` + OBX) + from the subset of the IG segment maps whose **inverse is one-to-one**. The **trigger is a required + argument on every entry point** and is never inferred: no FHIR resource carries one. + **▶ THE IG PUBLISHES NO FHIR-TO-V2 MAP**, so a many-to-one forward row has no usable inverse and is + refused, never resolved to its most likely source code; and **round-trip is asserted only as + "parses back", never as "equals"**. The `Patient` + `Encounter` visit-carrying ADT is **deferred, + not dropped**: the vendored parser exports no ADT assembly entry point (measured, zero occurrences + in its `dist/`), and hand-assembling PID + PV1 here would invert the tier split. Every measurement, + the refusal set, and the deferral: + `documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. Phase **8 + (profiles)** and deeper terminology remain deferred. - **Never quote a version here.** This line read "not yet published to npm" for several releases after first publish, which is part of why a `VERSION` constant stuck at `"0.0.0"` shipped unnoticed. Derive it: `npm view @cosyte/transform version`. diff --git a/README.md b/README.md index a0acf2b..e2ba370 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh > via `toFhir(msg)`, and **terminology value translation** of coded fields: route/site, > appointment type, order priority, and substitution are now value-translated through their IG > `mappedVia` ConceptMaps via `toFhirCodeableConceptVia`, fail-safe on any code the IG leaves unmapped. -> The v2→FHIR direction is feature-complete for the IG-covered message set; deeper -> terminology, profiles, and the reverse FHIR → v2 direction are not implemented. +> The v2→FHIR direction is feature-complete for the IG-covered message set. It also ships a +> **narrow reverse path**, FHIR → v2: `toV2Patient(patient, trigger)` and +> `toV2Observation(observation, trigger)` emit a complete v2 message carrying a `PID` or an `OBX`. +> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented. ## Install @@ -134,6 +136,44 @@ its `(unmapped)` group is flagged, never coerced to a neighbour. Fields whose IG (RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented. +## Emit v2 back out, narrowly + +Two entry points go the other way, FHIR → v2. Each takes the FHIR resource **plus the v2 trigger you +want the message to carry**, and returns the same `{ value, issues }` envelope, where `value` is a +complete `@cosyte/hl7` message: + +| Function | in | out | +| --------------------------------------- | ------------------ | ------------------------------------------ | +| `toV2Patient(patient, trigger)` | FHIR `Patient` | a v2 `ADT^` message with a `PID` | +| `toV2Observation(observation, trigger)` | FHIR `Observation` | a v2 `ORU^` message with an `OBX` | + +```ts +import { parseResource } from "@cosyte/fhir"; +import { toV2Patient } from "@cosyte/transform"; + +const { resource } = parseResource(patientJson); +const { value, issues } = toV2Patient(resource, "A28", { + assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" }, + envelope: { sendingApp: "EHR", sendingFacility: "MAIN" }, +}); +// value.toString() -> "MSH|^~\\&|EHR|MAIN|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r" +``` + +**The trigger is required and is never inferred.** No FHIR resource carries an HL7 v2 message +trigger, so there is nothing to derive one from: supply it, or the call returns no message and one +`TRANSFORM_MISSING_TRIGGER` diagnostic, without building anything. + +**This direction is lossy by design, and it is not a round-trip.** The published mapping guide runs +v2 → FHIR, and several of its rows are many-to-one, so their inverse is ambiguous and is **refused**: +`gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and +`Observation.status` `entered-in-error` each leave their v2 field absent with a +`TRANSFORM_CODE_NOT_INVERTIBLE` diagnostic rather than picking one of the v2 codes that could have +produced them. An element with no v2 field in this narrow map is flagged +(`TRANSFORM_NO_V2_TARGET`), a value v2 cannot carry unchanged is flagged and left out +(`TRANSFORM_VALUE_NOT_REPRESENTABLE`), and a coding system with no v2 mnemonic is flagged rather than +written under a borrowed table (`TRANSFORM_CODE_SYSTEM_NOT_V2`). Nothing here reconstructs the +message a resource came from, and nothing claims to. + ## License MIT © Cosyte diff --git a/docs-content/guides-overview.md b/docs-content/guides-overview.md index d75dae6..b1a95f2 100644 --- a/docs-content/guides-overview.md +++ b/docs-content/guides-overview.md @@ -41,6 +41,42 @@ toFhirDateTime(parseDtm("20260721143000"), { assumeTimezoneOffsetMinutes: -300 } // => { value: "2026-07-21T14:30:00-05:00", issues: [ TRANSFORM_TIMESTAMP_NO_TIMEZONE ] } ``` +## Emit a v2 message from a FHIR resource + +The reverse path is narrow on purpose: a `Patient` becomes an `ADT`-shaped message carrying a `PID`, +an `Observation` becomes an `ORU`-shaped message carrying an `OBX`. **You supply the trigger.** No +FHIR resource carries an HL7 v2 message trigger, so there is nothing to infer one from, and an +absent one returns no message plus a `TRANSFORM_MISSING_TRIGGER` diagnostic. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","gender":"female"}'); + +const emitted = toV2Patient(resource, "A28"); +emitted.value?.toString().includes("ADT^A28"); // => true + +const refused = toV2Patient(resource, ""); +refused.value; // => undefined +refused.issues[0]?.code === ISSUE_CODES.TRANSFORM_MISSING_TRIGGER; // => true +``` + +The direction is **lossy by design and not a round-trip**. The mapping guide runs v2 to FHIR, and +several of its rows are many-to-one, so their inverse is ambiguous: `gender` `other` could have come +from three different v2 codes, so it is refused rather than resolved to one of them. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","gender":"other"}'); +const { issues } = toV2Patient(resource, "A28"); + +issues[0]?.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE; // => true +issues[0]?.v2Location; // => "PID.8" +``` + ## Planned guides Not yet written: assembling a full `Patient`/`Encounter`/`Observation` graph from a message, diff --git a/docs-content/intro.md b/docs-content/intro.md index 836262d..df40443 100644 --- a/docs-content/intro.md +++ b/docs-content/intro.md @@ -23,8 +23,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh > Immunization**, **SIU_S12 → Appointment**, and **MDM_T02 → DocumentReference**, plus > **terminology value translation** of coded fields: route/site, appointment type, order > priority, and substitution translated through their IG `mappedVia` ConceptMaps. The -> v2→FHIR direction is feature-complete for the IG-covered message set; deeper terminology, profiles, -> and the reverse FHIR → v2 direction are not implemented. +> v2→FHIR direction is feature-complete for the IG-covered message set. A **narrow reverse path** +> also ships, FHIR → v2: `toV2Patient(patient, trigger)` and `toV2Observation(observation, trigger)` +> emit a complete v2 message carrying a `PID` or an `OBX`, lossy by design and never a round-trip. +> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented. ## The fail-safe promise diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index ed5c882..f7516ef 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -50,8 +50,17 @@ resource values; those carry PHI. ORU^R01 → DiagnosticReport + Observation, ORM_O01 / OML_O21 → ServiceRequest and RXO → MedicationRequest, and the thin IG singles VXU_V04 → Immunization, SIU_S12 → Appointment, and MDM_T02 → DocumentReference. The v2→FHIR direction is - feature-complete for the IG-covered message set; terminology depth, profiles, and the reverse - FHIR → v2 direction are not implemented. + feature-complete for the IG-covered message set; terminology depth and profiles are not + implemented. +- **Reverse (FHIR → v2) scope: two shapes, deliberately.** `toV2Patient` emits an `ADT`-shaped + message carrying a `PID`, `toV2Observation` an `ORU`-shaped message carrying an `OBX`. Both + require the caller to pass the v2 trigger (no resource carries one, so it is never inferred: a + missing one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic). The direction is + **lossy by design and not a round-trip**: a mapping row whose inverse is ambiguous is refused + (`TRANSFORM_CODE_NOT_INVERTIBLE`), an element with no v2 field in this map is flagged + (`TRANSFORM_NO_V2_TARGET`), and a value v2 cannot carry unchanged is left out + (`TRANSFORM_VALUE_NOT_REPRESENTABLE`). Emitting a `Patient` **and** an `Encounter` together as a + visit-carrying ADT is not implemented. - **Thin-IG-single scope**: each family covers the single trigger the IG maps and the resource-internal fields; references to resources this tier does not yet build (Immunization performer/manufacturer/location, Appointment practitioner/location participants, DocumentReference diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 0351514..19abdea 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -68,9 +68,57 @@ flagged, never coerced; SNOMED-target maps (RXR-4 method, SCH-7 reason) stay str SNOMED bundled; and fields with no IG value map (TXA-2, RXA-5) are documented as structural, never invented. +Phase 7 opened the **reverse direction, narrowly**: `toV2Patient` and `toV2Observation` emit a +complete v2 message carrying a `PID` or an `OBX`, each requiring a caller-supplied trigger. Two of +the three shapes the phase scoped shipped; the `Patient` + `Encounter` visit-carrying ADT did not. +The measurements, the refusals and the deferral are in +`documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. + **Deferred to later phases:** deeper terminology (the full HL7 THO NamingSystem crosswalk beyond the -shipped value maps, consumer-supplied ConceptMap application), the reverse FHIR→v2 direction (Phase 7), -and profiles (Phase 8). +shipped value maps, consumer-supplied ConceptMap application), the rest of the reverse FHIR→v2 +direction, and profiles (Phase 8). + +## The reverse direction, and what it does not claim + +**The mapping authority runs one way.** The IG is a **v2 to FHIR** guide; it publishes no FHIR to v2 +map. So every reverse row here is the *inverse* of a published row, and an inverse is only usable +where the forward row is one-to-one. `invertCodeMap` is that rule as code: it keeps a target only +when exactly one source maps to it, and drops the rest. Measured against the shipped forward maps, +the dropped set is `gender` `other` (Table 0001 `O`/`A`/`N`), name use `official` (`L`/`R`) and +`temp` (`NAV`/`TEMP`), address use `work` (`B`/`O`), every `Address.type` row (`M`/`SH` both mean +`postal`), and `Observation.status` `entered-in-error` (`D`/`W`). Each one raises +`TRANSFORM_CODE_NOT_INVERTIBLE` and leaves the v2 field absent. **Do not "fix" one by picking the +most likely source code**: that is a confident wrong value in the one direction where the reader is +a clinical system, not a person. + +**Round-trip is exercised as "parses back", never as "equals".** `test/reverse/property.test.ts` +asserts that every emitted message starts `MSH|`, parses under `parseHL7` without a fatal error, and +carries the caller's trigger verbatim in MSH-9. It never asserts that a v2 message transformed to +FHIR and back equals the original, and no shipped text says it does. A bare segment would fail the +parse outright (`parseHL7` fatally rejects input whose first segment is not `MSH`), which is why +each shape emits a whole message. + +**The trigger is a required argument because nothing else can supply it.** No FHIR resource carries +an HL7 v2 message trigger. Missing, empty or non-string returns `TRANSFORM_MISSING_TRIGGER` with no +builder call at all; a string that is not *bare* (whitespace, or an HL7 delimiter, which `^` would +turn into further MSH-9 components) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE`, because it could +not be written verbatim into MSH-9.2 and trimming it would emit something the caller did not ask +for. Both were found by the fuzz suite, not by reading. + +**Composites are structured, never concatenated.** Field content goes to `addSegment` as a +`RawField` of components, so the serializer owns escaping: a family name of `Do^e|Public` emits as +`Do\S\e\F\Public` and reads back identically, where a hand-joined `"Do^e"` string would have become +two components. Nothing in `src/reverse` writes a `^` or a `~`. + +**Deferred, and why: the `Patient` + `Encounter` visit-carrying ADT.** The vendored `@cosyte/hl7` +this repository actually builds and tests against exports no ADT assembly entry point (no +`buildAdt`, and no `buildOru`/`encodeComposite` either): measured on the installed package, zero +occurrences in both `dist/index.d.ts` and `dist/index.mjs`. Assembling PID + PV1 by hand instead +would be this package inventing a message-structure layout that the parser tier owns, which is the +opposite of the tier split ADR 0001 draws. The mapping itself is not the blocker and the deferral is +dated and written out in `documentation/decisions/`. **Re-measure the vendored package before +picking it up again** (`pnpm vendor:refresh` is a by-hand job, and the tarballs are unwatched by both +Dependabot routes): the entry point may exist in a later parser release than the one vendored here. ## Publish state, and the stale claim inside it diff --git a/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md b/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md new file mode 100644 index 0000000..2326ab1 --- /dev/null +++ b/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md @@ -0,0 +1,75 @@ +# 0003: The reverse direction ships narrowly, and the visit-carrying ADT is deferred + +- **Status:** Accepted (2026-08-14) +- **Scope:** `@cosyte/transform` (`src/reverse`, `toV2Patient`, `toV2Observation`) +- **Relates to:** transform roadmap (`operations/roadmaps/transform.md` §Phase 7), ADR 0001 (the + transformation tier may depend on the parser tier, and does not re-implement it), umbrella ADR 0018 + (grounded on the published map, never invented). + +## Context + +Phase 7 scoped three reverse (FHIR to v2) shapes: `Patient` to a message carrying a `PID`, +`Observation` to a message carrying an `OBX`, and `Patient` + `Encounter` to a **visit-carrying ADT** +assembled through the parser tier's own ADT entry point. + +Two facts, both measured against the checkout rather than assumed, decided how much of that shipped. + +1. **The IG publishes no FHIR-to-v2 map.** `hl7.fhir.uv.v2mappings` maps v2 **to** FHIR. Every row + used in reverse here is therefore the *inverse* of a published row, and an inverse is only usable + where the forward row is one-to-one. Several of the rows this package already ships are + many-to-one: three v2 administrative-sex codes mean `other`, two name types mean `official`, two + mean `temp`, two address types mean `work`, two mean `postal`, and two result statuses mean + `entered-in-error`. + +2. **The vendored parser exports no ADT assembly entry point.** The `@cosyte/hl7` this repository + builds and tests against (the `vendor/` tarball, which is a by-hand `pnpm vendor:refresh` job and + is watched by neither Dependabot route) exports `buildMessage`, `Hl7Message.addSegment` and + `parseHL7`, and **zero** occurrences of an ADT builder, an ORU builder, or a composite encoder in + either `dist/index.d.ts` or `dist/index.mjs`. + +## Decision + +1. **Ship the two shapes that ground out; defer the third, dated, rather than guess it.** + `toV2Patient` and `toV2Observation` ship. The `Patient` + `Encounter` visit-carrying ADT does not: + the mapping is not the blocker, the entry point is. Hand-assembling a PID + PV1 message-structure + layout inside `transform` would be this tier re-implementing what the parser tier owns, which + inverts ADR 0001's split for no safety gain. Re-measure the vendored parser before picking it up: + a later parser release may export it. + +2. **Where the inverse is not one-to-one, refuse and flag.** `invertCodeMap` keeps a target only when + exactly one source maps to it. Everything else raises `TRANSFORM_CODE_NOT_INVERTIBLE` and leaves + the v2 field **absent**. Resolving `other` to Table 0001 `O` because it looks likeliest would be a + confident wrong value, in the direction where the reader is another clinical system. + +3. **The trigger is a required argument, never inferred.** No FHIR resource carries an HL7 v2 message + trigger, so there is nothing to derive one from. A missing, empty or non-string trigger returns + `TRANSFORM_MISSING_TRIGGER` on the ordinary `{ value, issues }` channel, **before** any builder + call. A string that is not a bare trigger (whitespace, or a delimiter that would split MSH-9 into + further components) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE`, because it cannot be carried into + MSH-9.2 verbatim and trimming it would emit something the caller did not ask for. + +4. **The refusal codes live in `ISSUE_CODES`, not `FATAL_CODES`.** The split between the two + registries is structural in this package: a `FatalCode` is the type carried by a *thrown* error, + while `TransformIssue.code` is typed `IssueCode` and `ISSUE_REGISTRY` is exhaustive over + `ISSUE_CODES`. Since nothing here throws, every reverse refusal is returned, so it is an + `ISSUE_CODES` addition. Keying them under `FATAL_CODES` would have meant widening + `TransformIssue.code` and blurring the very split the module documents. Both registries stay + additions-only: no existing key was renamed or removed. + +5. **Emit whole messages, and verify only that they parse back.** Each shape returns the complete + message (`buildMessage(...).addSegment(...)`), never a bare segment, which the parser would + fatally reject for having no leading `MSH`. The property suite asserts *parses back under + `parseHL7` without a fatal error*, and that MSH-9 carries the caller's trigger verbatim. It does + **not** assert, and no shipped text claims, that a message transformed to FHIR and back equals the + original: this direction is lossy by construction. + +## Consequences + +- **Positive.** A consumer who already trusts the forward direction can emit demographics and result + observations back out, with every loss surfaced as a typed, value-free diagnostic instead of a + silent approximation. The bijective-subset rule is executable (`invertCodeMap`) rather than a + comment, so it cannot drift from the forward maps it inverts. +- **Negative / cost.** The reverse output is deliberately thin: an identifier's assigning authority + appears only when the caller seeds it, several codes are refused outright, and there is no + visit-carrying ADT at all. Consumers who want a fuller message must supply the missing context + themselves, and this package will keep refusing to invent it. diff --git a/src/diagnostics/codes.ts b/src/diagnostics/codes.ts index 481f469..d63b839 100644 --- a/src/diagnostics/codes.ts +++ b/src/diagnostics/codes.ts @@ -14,6 +14,16 @@ * entry points. **Nothing in this library throws one today**: the datatype converters and * `toFhir` alike fail safe to a value-free issue instead. * + * **The line between the two registries is structural, and it decides where a new code goes.** A + * `FatalCode` is the type carried by a *thrown* error; a `TransformIssue.code` is typed `IssueCode` + * and {@link ISSUE_REGISTRY} is exhaustive over {@link ISSUE_CODES} alone. So a condition that is + * *returned* on the non-throwing `{ value, issues }` channel is an {@link ISSUE_CODES} entry by + * construction, however severe it is: the missing-trigger refusal on the reverse + * (FHIR to v2) entry points is returned, never thrown, so it is + * {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} rather than a fatal code. Adding it to + * {@link FATAL_CODES} instead would have required widening `TransformIssue.code` to + * `IssueCode | FatalCode` and blurring exactly that split. + * * @packageDocumentation */ @@ -78,6 +88,33 @@ export const ISSUE_CODES = { * emitted with a `data-absent-reason` extension (value `unknown`) rather than fabricated, e.g. * `MessageHeader.source.endpoint` from an MSH-3 application namespace that is not a URL. */ TRANSFORM_REQUIRED_ELEMENT_UNKNOWN: "TRANSFORM_REQUIRED_ELEMENT_UNKNOWN", + /** A reverse (FHIR to v2) conversion was called without the explicit message trigger it requires. + * No FHIR resource carries a v2 trigger, so it is never inferred from resource content and never + * defaulted: no message is built and no builder is called. */ + TRANSFORM_MISSING_TRIGGER: "TRANSFORM_MISSING_TRIGGER", + /** A reverse (FHIR to v2) conversion was handed a resource outside the narrow set of shapes it + * supports. No v2 message is emitted and no segment layout is guessed. */ + TRANSFORM_UNSUPPORTED_RESOURCE: "TRANSFORM_UNSUPPORTED_RESOURCE", + /** A resource handed to a reverse (FHIR to v2) conversion is not structurally a FHIR resource of + * its expected type (not an object, no `resourceType`, or an element carrying the wrong node + * kind). Nothing is emitted and nothing is thrown. */ + TRANSFORM_RESOURCE_MALFORMED: "TRANSFORM_RESOURCE_MALFORMED", + /** A populated FHIR element has no v2 field in the narrow reverse map that covers this shape + * (the IG map defines no source for it, or its inverse is not implemented here). The element is + * surfaced and left out, never approximated into a neighbouring v2 field. */ + TRANSFORM_NO_V2_TARGET: "TRANSFORM_NO_V2_TARGET", + /** A FHIR value cannot be carried into its v2 target without altering it (a lexical form v2 has + * no representation for, or content that exceeds the target's component structure). The v2 field + * is left absent rather than truncated, rounded, or coerced. */ + TRANSFORM_VALUE_NOT_REPRESENTABLE: "TRANSFORM_VALUE_NOT_REPRESENTABLE", + /** A FHIR code has no usable inverse in the IG ConceptMap that governs its field: either several + * v2 source codes map onto it (so the inverse is ambiguous) or the map has no row for it at all. + * The v2 field is left absent, never resolved to one arbitrary member of the ambiguous set. */ + TRANSFORM_CODE_NOT_INVERTIBLE: "TRANSFORM_CODE_NOT_INVERTIBLE", + /** A `Coding.system` is not a system this library can name with a v2 coding-system mnemonic, so + * the coding cannot be written into a v2 coded field. It is flagged rather than emitted with no + * table context or with a code from an unrelated table. */ + TRANSFORM_CODE_SYSTEM_NOT_V2: "TRANSFORM_CODE_SYSTEM_NOT_V2", } as const; /** A value from {@link ISSUE_CODES}: the type consumers narrow `issue.code` against. */ diff --git a/src/diagnostics/issue.ts b/src/diagnostics/issue.ts index 01ad5cd..0919a8b 100644 --- a/src/diagnostics/issue.ts +++ b/src/diagnostics/issue.ts @@ -129,6 +129,48 @@ export const ISSUE_REGISTRY: Readonly> = Object.fre message: "required FHIR element could not be derived from the source; emitted with a data-absent-reason extension, never fabricated.", }, + [ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]: { + severity: "error", + fhirIssueType: "required", + message: + "reverse conversion requires an explicit v2 trigger from the caller; no resource carries one, so none was inferred and no message was built.", + }, + [ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE]: { + severity: "error", + fhirIssueType: "not-supported", + message: + "resource type is outside the reverse converter's supported shapes; no v2 message emitted and no segment layout guessed.", + }, + [ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED]: { + severity: "error", + fhirIssueType: "structure", + message: + "input is not a structurally well-shaped FHIR resource for this conversion; nothing emitted, nothing thrown.", + }, + [ISSUE_CODES.TRANSFORM_NO_V2_TARGET]: { + severity: "information", + fhirIssueType: "informational", + message: + "populated FHIR element has no v2 field in this reverse map; surfaced and left out, never approximated into a neighbouring field.", + }, + [ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE]: { + severity: "warning", + fhirIssueType: "value", + message: + "FHIR value cannot be carried into its v2 target without altering it; the v2 field is left absent, never truncated, rounded, or coerced.", + }, + [ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE]: { + severity: "warning", + fhirIssueType: "code-invalid", + message: + "FHIR code has no usable inverse in the governing IG ConceptMap (ambiguous or absent); v2 field left absent, never resolved arbitrarily.", + }, + [ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2]: { + severity: "warning", + fhirIssueType: "code-invalid", + message: + "coding system has no v2 coding-system mnemonic here; coding flagged rather than written with no table context or from an unrelated table.", + }, }); /** diff --git a/src/index.ts b/src/index.ts index 7de7bb5..7283c0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,15 @@ * all through `toFhir(msg)`), and the **terminology value-translation** layer: a * `$translate`-shaped {@link toFhirCodeableConceptVia} engine applying the license-clean IG value * ConceptMaps to the previously structural-only coded fields (route/site, appointment type, order - * priority, substitution). Terminology **depth beyond these maps**, profiles, and the reverse - * FHIR → v2 direction are not implemented. + * priority, substitution). + * + * It also ships a **narrow reverse path**, FHIR → v2: {@link toV2Patient} and + * {@link toV2Observation} emit a complete v2 message carrying a `PID` or an `OBX` built from the + * subset of the IG segment maps whose inverse is defensible. Each requires the caller to supply the + * v2 trigger, because no FHIR resource carries one. It is **lossy by design and not a round-trip**: + * a value the inverse of the IG map cannot ground is flagged and left absent, never guessed. + * Terminology **depth beyond these maps**, profiles, and any wider FHIR → v2 conversion are not + * implemented. * * @packageDocumentation */ @@ -77,7 +84,11 @@ export { ADMINISTRATIVE_GENDER_MAP } from "./messages/patient.js"; export { ENCOUNTER_CLASS_V3_MAP, ENCOUNTER_STATUS_MAP } from "./messages/encounter.js"; // ── Message-level assembly: HL7 v2 ORU^R01 → FHIR DiagnosticReport + Observation graph (Phase 3) ── -export { OBSERVATION_STATUS_MAP, HL70078_INTERPRETATION_CODES } from "./messages/observation.js"; +export { + OBSERVATION_STATUS_MAP, + HL70078_INTERPRETATION_CODES, + V3_OBSERVATION_INTERPRETATION_SYSTEM, +} from "./messages/observation.js"; export { DIAGNOSTIC_REPORT_STATUS_MAP } from "./messages/diagnostic-report.js"; // ── Message-level assembly: HL7 v2 ORM/OML → ServiceRequest, RXO → MedicationRequest (Phase 4) ──── @@ -109,3 +120,10 @@ export { } from "./terminology/concept-map.js"; export type { CodedTarget, CodedValueMap } from "./terminology/concept-map.js"; export { SERVICE_REQUEST_PRIORITY_MAP } from "./messages/service-request.js"; + +// ── The reverse direction: FHIR → a narrow, explicitly-scoped v2 message subset (Phase 7) ──────── +export { toV2Patient, GENDER_TO_V2, NAME_USE_TO_V2, ADDRESS_USE_TO_V2 } from "./reverse/patient.js"; +export { toV2Observation, OBSERVATION_STATUS_TO_V2 } from "./reverse/observation.js"; +export { invertCodeMap } from "./reverse/v2.js"; +export type { ReverseOptions } from "./reverse/coding.js"; +export type { ReverseResult } from "./reverse/message.js"; diff --git a/src/messages/observation.ts b/src/messages/observation.ts index 65b640c..de64c8a 100644 --- a/src/messages/observation.ts +++ b/src/messages/observation.ts @@ -49,8 +49,16 @@ import type { ConvertResult } from "../diagnostics/result.js"; import type { TransformContext } from "../terminology/context.js"; import { reference } from "./reference.js"; -/** The v3 ObservationInterpretation canonical system (FHIR `Observation.interpretation` binding). */ -const V3_OBSERVATION_INTERPRETATION_SYSTEM = +/** + * The v3 ObservationInterpretation canonical system (FHIR `Observation.interpretation` binding). + * + * @example + * ```ts + * import { V3_OBSERVATION_INTERPRETATION_SYSTEM } from "@cosyte/transform"; + * V3_OBSERVATION_INTERPRETATION_SYSTEM.endsWith("v3-ObservationInterpretation"); // => true + * ``` + */ +export const V3_OBSERVATION_INTERPRETATION_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"; /** diff --git a/src/reverse/coding.ts b/src/reverse/coding.ts new file mode 100644 index 0000000..cf4cc6b --- /dev/null +++ b/src/reverse/coding.ts @@ -0,0 +1,225 @@ +/** + * Codes and coding systems on the way out to v2, and the caller-supplied context that resolution + * needs. + * + * A v2 coded field names its vocabulary with a Table 0396 mnemonic (`LN`, `SCT`, `UCUM`), so writing + * a FHIR `Coding` into one means turning a canonical system URI back into that mnemonic. There is no + * algorithm for that, only a registry, so this module inverts the same seed the forward direction + * resolves through ({@link DEFAULT_V2_CODE_SYSTEMS} plus whatever the caller adds) and **refuses + * everything else**: an unrecognized system is flagged, never written into a coded field with no + * table context and never re-coded into a neighbouring table. + * + * The inversion keeps only URIs exactly one mnemonic names, for the same reason the code-map + * inversion does: two mnemonics for one URI is an ambiguity, not a choice to make silently. + * + * @packageDocumentation + */ + +import type { BuildMessageInit } from "@cosyte/hl7"; +import type { FhirComplex } from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { DEFAULT_V2_CODE_SYSTEMS } from "../terminology/naming-system.js"; +import { at, readComplexes, readString } from "./read.js"; +import { invertCodeMap, type V2Components } from "./v2.js"; + +/** + * Caller context for a reverse (FHIR to v2) conversion. Every entry is **caller-vetted**: nothing + * here is derived from resource content, and omitting all of it is safe (the conversion then flags + * what it cannot resolve rather than guessing it). + * + * @example + * ```ts + * import { toV2Patient } from "@cosyte/transform"; + * const options = { + * assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" }, + * envelope: { sendingApp: "EHR", sendingFacility: "MAIN" }, + * }; + * void options; + * void toV2Patient; + * ``` + */ +export interface ReverseOptions { + /** + * Extra v2 coding-system mnemonic to canonical URI entries, merged over + * {@link DEFAULT_V2_CODE_SYSTEMS} and then inverted. Same shape and direction as the forward + * registry's seed, so one declaration serves both directions. + */ + readonly codeSystems?: Readonly>; + /** + * `Identifier.system` URI to the v2 assigning-authority namespace (HD.1) that stands for it. There + * is no derivation from a URI to a namespace, so an identifier whose system is absent here is + * emitted with its value and **no** assigning authority, flagged rather than invented. + */ + readonly assigningAuthorities?: Readonly>; + /** + * MSH envelope fields for the emitted message (sending/receiving application and facility, control + * id, timestamp, version, processing id). The message type is never taken from here: it is fixed + * by the shape plus the caller's trigger argument. + */ + readonly envelope?: Omit; +} + +/** The resolved, inverted lookups one conversion runs against. */ +export interface ReverseContext { + /** The v2 Table 0396 mnemonic for a canonical system URI, or `undefined` when unresolvable. */ + readonly mnemonicFor: (system: string) => string | undefined; + /** The v2 assigning-authority namespace for an `Identifier.system`, or `undefined`. */ + readonly namespaceFor: (system: string) => string | undefined; + /** The MSH envelope fields to build the message with. */ + readonly envelope: Omit; +} + +/** + * Resolve a {@link ReverseOptions} into the lookups a conversion consults. + * + * @param options - The caller's reverse options. + * @example + * ```ts + * // reverseContext({ codeSystems: { LOCAL: "http://example.org/cs" } }).mnemonicFor("http://loinc.org") + * // => "LN" + * ``` + */ +export function reverseContext(options: ReverseOptions = {}): ReverseContext { + const mnemonics = invertCodeMap({ ...DEFAULT_V2_CODE_SYSTEMS, ...options.codeSystems }); + const authorities = options.assigningAuthorities ?? {}; + return { + mnemonicFor: (system) => (Object.hasOwn(mnemonics, system) ? mnemonics[system] : undefined), + namespaceFor: (system) => + Object.hasOwn(authorities, system) ? authorities[system] : undefined, + envelope: options.envelope ?? {}, + }; +} + +/** One `Coding`, read into the three v2 components a coded triplet carries. */ +interface V2Coding { + readonly code: string | undefined; + readonly display: string | undefined; + readonly mnemonic: string; +} + +/** + * Read one `Coding` into its v2 triplet, or `undefined` when its system has no v2 mnemonic (flagged) + * or it carries no code at all. + */ +function readCoding( + coding: FhirComplex, + ctx: ReverseContext, + location: string, + fhirPath: string, + issues: TransformIssue[], +): V2Coding | undefined { + const code = readString(at(coding, "code"), location, `${fhirPath}.code`, issues); + const display = readString(at(coding, "display"), location, `${fhirPath}.display`, issues); + const system = readString(at(coding, "system"), location, `${fhirPath}.system`, issues); + if (code === undefined) return undefined; + const mnemonic = system === undefined ? undefined : ctx.mnemonicFor(system); + if (mnemonic === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, location, `${fhirPath}.system`)); + return undefined; + } + return { code, display, mnemonic }; +} + +/** + * A FHIR `CodeableConcept` as CWE components (CWE.1/2/3 primary triplet, CWE.4/5/6 alternate, + * CWE.9 original text), or `undefined` when nothing in it can be written to a v2 coded field. + * + * Codings whose system has no v2 mnemonic are flagged and left out: the concept degrades to the + * codings that resolve, and to `CodeableConcept.text` when none do, which is the exact inverse of + * the CWE to CodeableConcept map's `CWE.9` row. + * + * @param concept - The `CodeableConcept` node. + * @param ctx - The resolved reverse context. + * @param location - The v2 target location (e.g. `"OBX.3"`), for value-free issues. + * @param fhirPath - The FHIR path being converted. + * @param issues - The issue sink. + * @example + * ```ts + * // codeableToCwe(observationCode, ctx, "OBX.3", "Observation.code", issues) + * // => ["789-8", "Hemoglobin", "LN"] + * ``` + */ +export function codeableToCwe( + concept: FhirComplex, + ctx: ReverseContext, + location: string, + fhirPath: string, + issues: TransformIssue[], +): V2Components | undefined { + const codings: V2Coding[] = []; + for (const coding of readComplexes( + at(concept, "coding"), + location, + `${fhirPath}.coding`, + issues, + )) { + const read = readCoding(coding, ctx, location, `${fhirPath}.coding`, issues); + if (read !== undefined) codings.push(read); + } + const text = readString(at(concept, "text"), location, `${fhirPath}.text`, issues); + if (codings.length === 0 && text === undefined) return undefined; + + // A third and further coding has no CWE slot: CWE carries a primary and one alternate triplet. + if (codings.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, location, `${fhirPath}.coding`), + ); + } + const primary = codings[0]; + const alternate = codings[1]; + return [ + primary?.code, + primary?.display, + primary?.mnemonic, + alternate?.code, + alternate?.display, + alternate?.mnemonic, + undefined, + undefined, + text, + ]; +} + +/** + * The code a concept carries in one specific system, or `undefined` when it carries none there. + * Used where a v2 field is a bare table code rather than a full CWE (an identifier type, an abnormal + * flag): a coding from any other system is flagged, never re-read as if it were from this table. + * + * @param concept - The `CodeableConcept` node. + * @param system - The canonical system URI the v2 table corresponds to. + * @param location - The v2 target location, for value-free issues. + * @param fhirPath - The FHIR path being converted. + * @param issues - The issue sink. + * @example + * ```ts + * // codeInSystem(identifierType, V2_0203_SYSTEM, "CX.5", "Identifier.type", issues) -> "MR" + * ``` + */ +export function codeInSystem( + concept: FhirComplex, + system: string, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + let found: string | undefined; + let foreign = false; + for (const coding of readComplexes( + at(concept, "coding"), + location, + `${fhirPath}.coding`, + issues, + )) { + const codingSystem = readString(at(coding, "system"), location, `${fhirPath}.system`, issues); + const code = readString(at(coding, "code"), location, `${fhirPath}.code`, issues); + if (code === undefined) continue; + if (codingSystem === system) found ??= code; + else foreign = true; + } + if (found === undefined && foreign) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, location, `${fhirPath}.system`)); + } + return found; +} diff --git a/src/reverse/message.ts b/src/reverse/message.ts new file mode 100644 index 0000000..c4ba6ee --- /dev/null +++ b/src/reverse/message.ts @@ -0,0 +1,209 @@ +/** + * The scaffolding every reverse (FHIR to v2) entry point shares: the required trigger, the resource + * gate, and the message the mapped segment is carried in. + * + * **The trigger is always the caller's.** No FHIR resource carries an HL7 v2 message trigger: not + * `Patient`, not `Observation`, not `Encounter`. So it is a required argument, and a missing, empty + * or non-string one stops the conversion **before** any builder call, with + * {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} on the ordinary `{ value, issues }` channel. It is + * never defaulted, never derived from resource content, and never substituted with a placeholder. + * + * **What is emitted is a message, never a bare segment.** A segment on its own is not parseable HL7 + * (a v2 parser fatally rejects any input whose first segment is not `MSH`), so each shape builds a + * complete message through `buildMessage` and appends its mapped segment to it. The trigger is used + * verbatim as the trailing component of the fixed message code the shape itself owns. + * + * @packageDocumentation + */ + +import { buildMessage, type Hl7Message, type RawField } from "@cosyte/hl7"; +import { isComplex, resourceType, type FhirComplex, type FhirNode } from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import type { ConvertResult } from "../diagnostics/result.js"; +import type { ReverseContext } from "./coding.js"; +import { segmentFields } from "./v2.js"; + +/** + * What a reverse conversion returns: the complete `@cosyte/hl7` message it could faithfully build + * (or `undefined` when it could not), plus the value-free diagnostics it raised. The same fail-safe + * envelope the forward converters return, in the other direction. + * + * @example + * ```ts + * import { toV2Patient } from "@cosyte/transform"; + * // const { value, issues } = toV2Patient(patientNode, "A28"); + * // value?.toString() -> "MSH|^~\\&|...|ADT^A28|...\rPID|||MRN1\r" + * void toV2Patient; + * ``` + */ +export type ReverseResult = ConvertResult; + +/** + * The resource types this library names in a diagnostic. It is **library-owned vocabulary**: the + * resource types the forward direction produces, plus the reverse shapes' own inputs. An observed + * `resourceType` outside it is reported as the generic `Resource`, so no string taken from input + * content can reach a diagnostic through the unsupported-resource path. + */ +const NAMED_RESOURCES: ReadonlySet = new Set([ + "Appointment", + "Bundle", + "DiagnosticReport", + "DocumentReference", + "Encounter", + "Immunization", + "MedicationRequest", + "MessageHeader", + "Observation", + "OperationOutcome", + "Patient", + "RelatedPerson", + "ServiceRequest", +]); + +/** Whether a caller actually supplied a trigger at all (the parameter is typed, callers may not be). */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +/** A bare MSH-9.2 trigger: one token, no whitespace and none of the HL7 delimiter characters. */ +const BARE_TRIGGER = /^[^\s|^~\\&\r\n]+$/; + +/** + * Check the caller-supplied trigger. A `false` return means **no builder call may be made**, and one + * of two value-free issues has been raised: + * + * - {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} when it is missing, empty, or not a string. There + * is nothing to fall back to: no resource carries a trigger, so none is inferred or defaulted. + * - {@link ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE} when it is a string but not a *bare* + * trigger. A value carrying a component separator or whitespace cannot be written into MSH-9.2 + * verbatim (`^` would split it into further MSH-9 components, and padding does not survive a + * parse), so it is refused rather than trimmed or escaped into something the caller did not ask + * for. + * + * @param trigger - The caller's bare v2 trigger (e.g. `"A28"`). + * @param issues - The issue sink. + * @example + * ```ts + * // hasTrigger("A28", issues) -> true; hasTrigger("", issues) -> false + one issue + * ``` + */ +export function hasTrigger(trigger: string, issues: TransformIssue[]): boolean { + if (!isNonEmptyString(trigger)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_MISSING_TRIGGER, "MSH.9.2", "trigger")); + return false; + } + if (!BARE_TRIGGER.test(trigger)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "MSH.9.2", "trigger")); + return false; + } + return true; +} + +/** + * Gate an input node into the resource a shape accepts: a complex node carrying the expected + * `resourceType`. Anything else raises {@link ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED} (not a + * resource) or {@link ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE} (a resource this converter does + * not map), and reads as nothing to convert. + * + * @param input - The FHIR node handed to the converter. + * @param expected - The `resourceType` this shape converts. + * @param location - The v2 target segment, for value-free issues. + * @param issues - The issue sink. + * @example + * ```ts + * // readResource(node, "Patient", "PID", issues) -> the Patient node, or undefined + one issue + * ``` + */ +export function readResource( + input: FhirNode, + expected: string, + location: string, + issues: TransformIssue[], +): FhirComplex | undefined { + if (!isComplex(input)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, "Resource")); + return undefined; + } + const type = resourceType(input); + if (type === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, "Resource.resourceType")); + return undefined; + } + if (type !== expected) { + const named = NAMED_RESOURCES.has(type) ? type : "Resource"; + issues.push(issue(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE, location, named)); + return undefined; + } + return input; +} + +/** + * Flag every populated element of a resource that this reverse map does not carry into v2, one + * value-free issue each. Element **names** are reported only from the supplied table, which is + * library-owned vocabulary; an element outside it is reported against the resource itself, so no + * name taken from input content reaches a diagnostic. + * + * @param resource - The gated resource node. + * @param mapped - The element names this shape does convert. + * @param targets - The known-but-unconverted element names, each with the v2 location it would take. + * @param resourceName - The resource type, for the FHIR path. + * @param segment - The v2 segment, used when an element has no location of its own. + * @param issues - The issue sink. + * @example + * ```ts + * // flagUnmapped(patient, PATIENT_MAPPED, PATIENT_UNMAPPED, "Patient", "PID", issues) + * // -> one TRANSFORM_NO_V2_TARGET per populated element outside PATIENT_MAPPED + * ``` + */ +export function flagUnmapped( + resource: FhirComplex, + mapped: ReadonlySet, + targets: Readonly>, + resourceName: string, + segment: string, + issues: TransformIssue[], +): void { + for (const property of resource.properties) { + if (mapped.has(property.name)) continue; + const known = Object.hasOwn(targets, property.name); + issues.push( + issue( + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + known ? (targets[property.name] ?? segment) : segment, + known ? `${resourceName}.${property.name}` : resourceName, + ), + ); + } +} + +/** + * Build the complete message for a shape: `buildMessage` with the shape's fixed message code and the + * caller's trigger, then the mapped segment appended to it. Returns `undefined` when the segment + * would carry no field at all, so an empty segment is never emitted. + * + * @param messageCode - The shape's fixed MSH-9.1 message code (`"ADT"`, `"ORU"`). + * @param trigger - The caller's bare trigger, used verbatim as MSH-9.2. + * @param segment - The segment name to append (`"PID"`, `"OBX"`). + * @param byPosition - The mapped fields, keyed by 1-based HL7 field position. + * @param ctx - The resolved reverse context (its `envelope` supplies the MSH fields). + * @example + * ```ts + * // emitMessage("ADT", "A28", "PID", fields, ctx)?.toString() + * // -> "MSH|^~\\&|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r" + * ``` + */ +export function emitMessage( + messageCode: string, + trigger: string, + segment: string, + byPosition: ReadonlyMap, + ctx: ReverseContext, +): Hl7Message | undefined { + if (byPosition.size === 0) return undefined; + return buildMessage({ ...ctx.envelope, type: `${messageCode}^${trigger}` }).addSegment( + segment, + segmentFields(byPosition), + ); +} diff --git a/src/reverse/observation.ts b/src/reverse/observation.ts new file mode 100644 index 0000000..ecc4400 --- /dev/null +++ b/src/reverse/observation.ts @@ -0,0 +1,423 @@ +/** + * FHIR `Observation` to a v2 message carrying an `OBX` segment: the inverse of the IG **Segment OBX + * to Observation** ConceptMap, over the rows whose inverse is defensible. + * + * | FHIR element | OBX field | inverse | + * |---|---|---| + * | `Observation.code` | OBX-3 (CWE) | coding to CWE.1/2/3 (+ one alternate triplet), `text` to CWE.9 | + * | `Observation.value[x]` | OBX-2 + OBX-5 | the value type OBX-2 declares is derived from which `value[x]` is present | + * | `Observation.valueQuantity` units | OBX-6 (CWE) | UCUM code/display, other systems flagged | + * | `Observation.referenceRange.text` | OBX-7 | carried verbatim, never composed from `low`/`high` | + * | `Observation.interpretation` | OBX-8 | v3 ObservationInterpretation codes the HL70078 map carries | + * | `Observation.status` | OBX-11 | Table 0085 where the map inverts | + * | `Observation.effectiveDateTime` | OBX-14 | lexical timestamp, precision preserved | + * + * **OBX-2 is derived, never assumed.** `valueQuantity` writes `NM` (or `SN` when the quantity + * carries a comparator, which is the only OBX-2 type that can hold one), `valueCodeableConcept` + * writes `CWE`, `valueString` writes `ST`, `valueDateTime` writes `DTM`. A `value[x]` with no + * faithful OBX-5 form (`valueRange`, `valueRatio`, `valueBoolean`, ...) writes **nothing** and is + * flagged: emitting the magnitude of a comparator-bearing or ranged value as a bare number would be + * a confidently wrong result, which is the one thing this library never does. + * + * `Observation.subject` and `.encounter` are flagged rather than emitted: this shape's input is an + * `Observation` alone, and a PID or PV1 assembled from a bare reference would be fabricated. + * + * @packageDocumentation + */ + +import type { FhirComplex, FhirNode } from "@cosyte/fhir"; +import type { RawField } from "@cosyte/hl7"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { + HL70078_INTERPRETATION_CODES, + OBSERVATION_STATUS_MAP, + V3_OBSERVATION_INTERPRETATION_SYSTEM, +} from "../messages/observation.js"; +import { + codeableToCwe, + codeInSystem, + reverseContext, + type ReverseContext, + type ReverseOptions, +} from "./coding.js"; +import { + emitMessage, + flagUnmapped, + hasTrigger, + readResource, + type ReverseResult, +} from "./message.js"; +import { at, readComplexes, readNumberText, readString } from "./read.js"; +import { invertCodeMap, v2Field, v2Number, v2Timestamp, type V2Components } from "./v2.js"; + +/** + * FHIR `observation-status` to HL7 v2 Table 0085, the invertible rows of the IG's **HL70085 to + * Observation Status** map. `entered-in-error` is absent on purpose (`D` and `W` both carry it), as + * are the FHIR statuses the map never targets (`registered`, `unknown`). + * + * @example + * ```ts + * import { OBSERVATION_STATUS_TO_V2 } from "@cosyte/transform"; + * OBSERVATION_STATUS_TO_V2["corrected"]; // => "C" + * OBSERVATION_STATUS_TO_V2["entered-in-error"]; // => undefined (ambiguous inverse, flagged instead) + * ``` + */ +export const OBSERVATION_STATUS_TO_V2: Readonly> = + invertCodeMap(OBSERVATION_STATUS_MAP); + +/** The FHIR `Quantity.comparator` codes an SN can carry (SN has no "not equal" comparator). */ +const SN_COMPARATORS: ReadonlySet = new Set(["<", "<=", ">=", ">"]); + +/** The `Observation` elements this map carries into OBX. */ +const OBSERVATION_MAPPED: ReadonlySet = new Set([ + "resourceType", + "code", + "status", + "effectiveDateTime", + "interpretation", + "referenceRange", + "valueQuantity", + "valueCodeableConcept", + "valueString", + "valueDateTime", +]); + +/** `Observation` elements with a known OBX/OBR home this narrow map does not implement. */ +const OBSERVATION_UNMAPPED: Readonly> = Object.freeze({ + valueBoolean: "OBX.5", + valueInteger: "OBX.5", + valueRange: "OBX.5", + valueRatio: "OBX.5", + valueTime: "OBX.5", + valuePeriod: "OBX.5", + valueSampledData: "OBX.5", + valueAttachment: "OBX.5", + dataAbsentReason: "OBX.5", + effectivePeriod: "OBX.14", + effectiveInstant: "OBX.14", + issued: "OBX.19", + performer: "OBX.16", + method: "OBX.17", + specimen: "OBX.18", + device: "OBX.18", + bodySite: "OBX.20", + identifier: "OBX.21", + note: "NTE", + subject: "PID", + encounter: "PV1", + basedOn: "OBR", + category: "OBR", + component: "OBX", + hasMember: "OBX", + derivedFrom: "OBX", +}); + +/** The units CWE for a quantity, or `undefined` when it carries no unit at all. */ +function unitComponents( + quantity: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): V2Components | undefined { + const unit = readString(at(quantity, "unit"), "OBX.6", "Observation.valueQuantity.unit", issues); + const code = readString(at(quantity, "code"), "OBX.6", "Observation.valueQuantity.code", issues); + const system = readString( + at(quantity, "system"), + "OBX.6", + "Observation.valueQuantity.system", + issues, + ); + const mnemonic = system === undefined ? undefined : ctx.mnemonicFor(system); + if (code !== undefined && mnemonic !== undefined) return [code, unit, mnemonic]; + if (code !== undefined) { + // A coded unit whose system has no v2 mnemonic (or none at all) cannot be written as a coded + // unit: the display text survives, the code does not get a borrowed table. + issues.push( + issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, "OBX.6", "Observation.valueQuantity.system"), + ); + } + return unit === undefined ? undefined : [undefined, unit]; +} + +/** OBX-2 / OBX-5 / OBX-6 for a `valueQuantity`. */ +function quantityValue( + quantity: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): { valueType: string; value: V2Components; units: V2Components | undefined } | undefined { + const raw = readNumberText( + at(quantity, "value"), + "OBX.5", + "Observation.valueQuantity.value", + issues, + ); + const magnitude = raw === undefined ? undefined : v2Number(raw); + if (magnitude === undefined) { + if (raw !== undefined) { + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.5", + "Observation.valueQuantity.value", + ), + ); + } + return undefined; + } + const units = unitComponents(quantity, ctx, issues); + const comparator = readString( + at(quantity, "comparator"), + "OBX.5", + "Observation.valueQuantity.comparator", + issues, + ); + if (comparator === undefined) return { valueType: "NM", value: [magnitude], units }; + if (!SN_COMPARATORS.has(comparator)) { + // Emitting the magnitude without its comparator would assert a different result: emit neither. + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.5", + "Observation.valueQuantity.comparator", + ), + ); + return undefined; + } + // SN.1 comparator, SN.2 number: the structured-numeric shape the IG's OBX-2 = SN row describes. + return { valueType: "SN", value: [comparator, magnitude], units }; +} + +/** OBX-2 / OBX-5 / OBX-6 for whichever `value[x]` the observation carries, if any. */ +function observationValue( + observation: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): { valueType: string; value: V2Components; units: V2Components | undefined } | undefined { + const quantity = readComplexes( + at(observation, "valueQuantity"), + "OBX.5", + "Observation.valueQuantity", + issues, + )[0]; + if (quantity !== undefined) return quantityValue(quantity, ctx, issues); + + const concept = readComplexes( + at(observation, "valueCodeableConcept"), + "OBX.5", + "Observation.valueCodeableConcept", + issues, + )[0]; + if (concept !== undefined) { + const cwe = codeableToCwe(concept, ctx, "OBX.5", "Observation.valueCodeableConcept", issues); + return cwe === undefined ? undefined : { valueType: "CWE", value: cwe, units: undefined }; + } + + const text = readString( + at(observation, "valueString"), + "OBX.5", + "Observation.valueString", + issues, + ); + if (text !== undefined) return { valueType: "ST", value: [text], units: undefined }; + + const instant = readString( + at(observation, "valueDateTime"), + "OBX.5", + "Observation.valueDateTime", + issues, + ); + if (instant !== undefined) { + const dtm = v2Timestamp(instant); + if (dtm === undefined) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "OBX.5", "Observation.valueDateTime"), + ); + return undefined; + } + return { valueType: "DTM", value: [dtm], units: undefined }; + } + return undefined; +} + +/** OBX-8: the abnormal flags whose v3 interpretation code the HL70078 map carries. */ +function interpretationField( + observation: FhirComplex, + issues: TransformIssue[], +): RawField | undefined { + const flags: V2Components[] = []; + for (const concept of readComplexes( + at(observation, "interpretation"), + "OBX.8", + "Observation.interpretation", + issues, + )) { + const code = codeInSystem( + concept, + V3_OBSERVATION_INTERPRETATION_SYSTEM, + "OBX.8", + "Observation.interpretation", + issues, + ); + if (code === undefined) continue; + // The HL70078 map is code-preserving, so a v3 code it carries is its own v2 flag; one it does + // not carry has no v2 abnormal flag and is never coerced to a neighbouring one. + if (HL70078_INTERPRETATION_CODES.has(code)) flags.push([code]); + else { + issues.push( + issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "OBX.8", "Observation.interpretation"), + ); + } + } + return v2Field(flags); +} + +/** OBX-7: the reference range's text, carried verbatim and never composed from its endpoints. */ +function referenceRangeField( + observation: FhirComplex, + issues: TransformIssue[], +): RawField | undefined { + const ranges = readComplexes( + at(observation, "referenceRange"), + "OBX.7", + "Observation.referenceRange", + issues, + ); + if (ranges.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "OBX.7", "Observation.referenceRange"), + ); + } + const first = ranges[0]; + if (first === undefined) return undefined; + const text = readString(at(first, "text"), "OBX.7", "Observation.referenceRange.text", issues); + if (text === undefined) { + // low/high are structured endpoints; OBX-7's own IG row is the text one, and assembling + // "3.5-5.0" from two Quantities would be composing a v2 value this map has no row for. + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "OBX.7", "Observation.referenceRange")); + return undefined; + } + return v2Field([[text]]); +} + +/** Every OBX field this map produces, keyed by HL7 field position. */ +function obxFields( + observation: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): ReadonlyMap { + const fields = new Map(); + + // OBX-3 is the observation identifier: without one there is no OBX to emit at all. + const concept = readComplexes(at(observation, "code"), "OBX.3", "Observation.code", issues)[0]; + if (concept === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, "OBX.3", "Observation.code")); + return fields; + } + const cwe = codeableToCwe(concept, ctx, "OBX.3", "Observation.code", issues); + const obx3 = cwe === undefined ? undefined : v2Field([cwe]); + if (obx3 === undefined) return fields; + fields.set(3, obx3); + + const value = observationValue(observation, ctx, issues); + if (value !== undefined) { + const obx2 = v2Field([[value.valueType]]); + const obx5 = v2Field([value.value]); + if (obx2 !== undefined && obx5 !== undefined) { + fields.set(2, obx2); + fields.set(5, obx5); + } + const obx6 = value.units === undefined ? undefined : v2Field([value.units]); + if (obx6 !== undefined) fields.set(6, obx6); + } + + const referenceRange = referenceRangeField(observation, issues); + if (referenceRange !== undefined) fields.set(7, referenceRange); + + const interpretation = interpretationField(observation, issues); + if (interpretation !== undefined) fields.set(8, interpretation); + + const status = readString(at(observation, "status"), "OBX.11", "Observation.status", issues); + if (status !== undefined) { + const code = Object.hasOwn(OBSERVATION_STATUS_TO_V2, status) + ? OBSERVATION_STATUS_TO_V2[status] + : undefined; + if (code === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "OBX.11", "Observation.status")); + } else { + const obx11 = v2Field([[code]]); + if (obx11 !== undefined) fields.set(11, obx11); + } + } + + const effective = readString( + at(observation, "effectiveDateTime"), + "OBX.14", + "Observation.effectiveDateTime", + issues, + ); + if (effective !== undefined) { + const dtm = v2Timestamp(effective); + if (dtm === undefined) { + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.14", + "Observation.effectiveDateTime", + ), + ); + } else { + const obx14 = v2Field([[dtm]]); + if (obx14 !== undefined) fields.set(14, obx14); + } + } + + return fields; +} + +/** + * Convert a FHIR R4 `Observation` into a **complete** v2 message carrying an `OBX` segment, in the + * result-report (`ORU`) shape. + * + * The `trigger` argument is **required and never inferred**: no `Observation` element maps to a v2 + * message trigger, so the caller supplies it, and a missing, empty or non-string one returns + * `{ value: undefined }` plus {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} without calling the + * message builder at all. It is used verbatim as MSH-9.2 under the `ORU` message code this shape + * fixes. + * + * The message carries the `OBX` alone: this shape's input is an `Observation`, which names no + * patient, and a subject segment assembled from a reference would be fabricated. Lossy by design and + * never round-trip-safe. + * + * @param resource - The FHIR `Observation` node. + * @param trigger - The bare v2 trigger, e.g. `"R01"`. Required; never derived from the resource. + * @param options - Caller-vetted reverse context: code systems and the MSH envelope. + * @example + * ```ts + * import { parseResource } from "@cosyte/fhir"; + * import { toV2Observation } from "@cosyte/transform"; + * + * const { resource } = parseResource( + * '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]}}', + * ); + * const { value, issues } = toV2Observation(resource, "R01"); + * // value.toString() carries "ORU^R01" in MSH-9, then an OBX whose OBX-3 is "789-8^^LN" + * void value; + * void issues; + * ``` + */ +export function toV2Observation( + resource: FhirNode, + trigger: string, + options: ReverseOptions = {}, +): ReverseResult { + const issues: TransformIssue[] = []; + const triggerOk = hasTrigger(trigger, issues); + const observation = readResource(resource, "Observation", "OBX", issues); + if (!triggerOk || observation === undefined) return { value: undefined, issues }; + + flagUnmapped(observation, OBSERVATION_MAPPED, OBSERVATION_UNMAPPED, "Observation", "OBX", issues); + const ctx = reverseContext(options); + const value = emitMessage("ORU", trigger, "OBX", obxFields(observation, ctx, issues), ctx); + return { value, issues }; +} diff --git a/src/reverse/patient.ts b/src/reverse/patient.ts new file mode 100644 index 0000000..6a661fd --- /dev/null +++ b/src/reverse/patient.ts @@ -0,0 +1,320 @@ +/** + * FHIR `Patient` to a v2 message carrying a `PID` segment: the inverse of the IG **Segment PID to + * Patient** ConceptMap, over the rows whose inverse is defensible. + * + * | FHIR element | PID field | inverse | + * |---|---|---| + * | `Patient.identifier` | PID-3 (CX) | value to CX.1, caller-vetted authority to CX.4, Table 0203 type to CX.5 | + * | `Patient.name` | PID-5 (XPN) | family/given/prefix/suffix to XPN.1-XPN.5, `use` to XPN.7 where the HL70200 map inverts | + * | `Patient.birthDate` | PID-7 (DTM) | lexical date, precision preserved | + * | `Patient.gender` | PID-8 | Table 0001 where the map inverts (`female`/`male`/`unknown`) | + * | `Patient.address` | PID-11 (XAD) | line/city/state/postalCode/country/district, `use` to XAD.7 where HL70190 inverts | + * + * **This is the lossy direction, and it says so rather than papering over it.** The published map is + * v2 to FHIR; several of its rows are many-to-one, so the inverse is ambiguous and is refused: + * `gender` `other` (Table 0001 `O`, `A` and `N` all mean it), name use `official` (`L` and `R`) and + * `temp` (`NAV` and `TEMP`), address use `work` (`B` and `O`), and every `Address.type` + * (`M` and `SH` both mean `postal`). Each leaves its v2 field absent and raises + * {@link ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE}. Nothing here reconstructs a v2 message that was + * transformed to FHIR: it writes what the FHIR resource itself supports, and flags the rest. + * + * @packageDocumentation + */ + +import type { FhirComplex, FhirNode } from "@cosyte/fhir"; +import type { RawField } from "@cosyte/hl7"; + +import { ADDRESS_USE_MAP } from "../datatypes/address.js"; +import { NAME_USE_MAP } from "../datatypes/human-name.js"; +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { ADMINISTRATIVE_GENDER_MAP } from "../messages/patient.js"; +import { V2_0203_SYSTEM } from "../terminology/naming-system.js"; +import { + codeInSystem, + reverseContext, + type ReverseContext, + type ReverseOptions, +} from "./coding.js"; +import { + emitMessage, + flagUnmapped, + hasTrigger, + readResource, + type ReverseResult, +} from "./message.js"; +import { at, readComplexes, readString, readStrings } from "./read.js"; +import { invertCodeMap, v2Date, v2Field, type V2Components } from "./v2.js"; + +/** + * FHIR `administrative-gender` to HL7 v2 Table 0001, the invertible rows of the IG's **HL70001 to + * Administrative Gender** map. `other` is absent on purpose: three source codes carry it. + * + * @example + * ```ts + * import { GENDER_TO_V2 } from "@cosyte/transform"; + * GENDER_TO_V2["female"]; // => "F" + * GENDER_TO_V2["other"]; // => undefined (ambiguous inverse, flagged instead) + * ``` + */ +export const GENDER_TO_V2: Readonly> = + invertCodeMap(ADMINISTRATIVE_GENDER_MAP); + +/** + * FHIR `name-use` to HL7 v2 Table 0200, the invertible rows of the IG's **HL70200 to name-use** map. + * `official` and `temp` are absent on purpose: two source codes each. + * + * @example + * ```ts + * import { NAME_USE_TO_V2 } from "@cosyte/transform"; + * NAME_USE_TO_V2["maiden"]; // => "M" + * ``` + */ +export const NAME_USE_TO_V2: Readonly> = invertCodeMap(NAME_USE_MAP); + +/** + * FHIR `address-use` to HL7 v2 Table 0190, the invertible rows of the IG's **HL70190 to + * address-use** map. `work` is absent on purpose (`B` and `O`), as is every `Address.type` row. + * + * @example + * ```ts + * import { ADDRESS_USE_TO_V2 } from "@cosyte/transform"; + * ADDRESS_USE_TO_V2["home"]; // => "H" + * ``` + */ +export const ADDRESS_USE_TO_V2: Readonly> = invertCodeMap(ADDRESS_USE_MAP); + +/** The `Patient` elements this map carries into PID. */ +const PATIENT_MAPPED: ReadonlySet = new Set([ + "resourceType", + "identifier", + "name", + "birthDate", + "gender", + "address", +]); + +/** `Patient` elements with a known PID home this narrow map does not implement. */ +const PATIENT_UNMAPPED: Readonly> = Object.freeze({ + telecom: "PID.13", + communication: "PID.15", + maritalStatus: "PID.16", + multipleBirthBoolean: "PID.24", + multipleBirthInteger: "PID.25", + deceasedDateTime: "PID.29", + deceasedBoolean: "PID.30", + contact: "NK1", +}); + +/** A code's inverse, or `undefined` plus a flag when the governing map does not invert it. */ +function invertible( + inverse: Readonly>, + code: string, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (Object.hasOwn(inverse, code)) return inverse[code]; + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, location, fhirPath)); + return undefined; +} + +/** One `Identifier` as CX components, or `undefined` when it carries no value to key on. */ +function identifierComponents( + identifier: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): V2Components | undefined { + const value = readString(at(identifier, "value"), "CX.1", "Patient.identifier.value", issues); + if (value === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "CX.1", "Patient.identifier")); + return undefined; + } + // CX.4 is an assigning authority, and no algorithm turns a system URI into one: only a + // caller-vetted namespace is written, and an unseeded system is flagged, never synthesized. + const system = readString(at(identifier, "system"), "CX.4", "Patient.identifier.system", issues); + let namespace: string | undefined; + if (system !== undefined) { + namespace = ctx.namespaceFor(system); + if (namespace === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "CX.4", "Patient.identifier.system")); + } + } + const typeConcept = readComplexes( + at(identifier, "type"), + "CX.5", + "Patient.identifier.type", + issues, + )[0]; + const typeCode = + typeConcept === undefined + ? undefined + : codeInSystem(typeConcept, V2_0203_SYSTEM, "CX.5", "Patient.identifier.type", issues); + return [value, undefined, undefined, namespace, typeCode]; +} + +/** One `HumanName` as XPN components. */ +function nameComponents(name: FhirComplex, issues: TransformIssue[]): V2Components { + const family = readString(at(name, "family"), "XPN.1", "Patient.name.family", issues); + const given = readStrings(at(name, "given"), "XPN.2", "Patient.name.given", issues); + const prefix = readStrings(at(name, "prefix"), "XPN.5", "Patient.name.prefix", issues); + const suffix = readStrings(at(name, "suffix"), "XPN.4", "Patient.name.suffix", issues); + const use = readString(at(name, "use"), "XPN.7", "Patient.name.use", issues); + + // XPN carries one further-given component, one prefix and one suffix: extras would have to be + // joined or dropped, and both alter the name, so they are flagged and left out. + if (given.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.3", "Patient.name.given"), + ); + } + if (prefix.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.5", "Patient.name.prefix"), + ); + } + if (suffix.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.4", "Patient.name.suffix"), + ); + } + const nameType = + use === undefined + ? undefined + : invertible(NAME_USE_TO_V2, use, "XPN.7", "Patient.name.use", issues); + return [family, given[0], given[1], suffix[0], prefix[0], undefined, nameType]; +} + +/** One `Address` as XAD components. */ +function addressComponents(address: FhirComplex, issues: TransformIssue[]): V2Components { + const line = readStrings(at(address, "line"), "XAD.1", "Patient.address.line", issues); + const city = readString(at(address, "city"), "XAD.3", "Patient.address.city", issues); + const state = readString(at(address, "state"), "XAD.4", "Patient.address.state", issues); + const postal = readString( + at(address, "postalCode"), + "XAD.5", + "Patient.address.postalCode", + issues, + ); + const country = readString(at(address, "country"), "XAD.6", "Patient.address.country", issues); + const district = readString(at(address, "district"), "XAD.9", "Patient.address.district", issues); + const use = readString(at(address, "use"), "XAD.7", "Patient.address.use", issues); + const type = readString(at(address, "type"), "XAD.7", "Patient.address.type", issues); + + if (line.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XAD.2", "Patient.address.line"), + ); + } + const addressType = + use === undefined + ? undefined + : invertible(ADDRESS_USE_TO_V2, use, "XAD.7", "Patient.address.use", issues); + // FHIR splits use (home/work) from type (postal/physical); XAD.7 is one code, and the IG's + // address-type rows are many-to-one, so a type is always flagged rather than merged into XAD.7. + if (type !== undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "XAD.7", "Patient.address.type")); + } + return [line[0], line[1], city, state, postal, country, addressType, undefined, district]; +} + +/** Every PID field this map produces, keyed by HL7 field position. */ +function pidFields( + patient: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): ReadonlyMap { + const fields = new Map(); + + const identifiers: V2Components[] = []; + for (const identifier of readComplexes( + at(patient, "identifier"), + "PID.3", + "Patient.identifier", + issues, + )) { + const components = identifierComponents(identifier, ctx, issues); + if (components !== undefined) identifiers.push(components); + } + const pid3 = v2Field(identifiers); + if (pid3 !== undefined) fields.set(3, pid3); + + const names = readComplexes(at(patient, "name"), "PID.5", "Patient.name", issues).map((name) => + nameComponents(name, issues), + ); + const pid5 = v2Field(names); + if (pid5 !== undefined) fields.set(5, pid5); + + const birthDate = readString(at(patient, "birthDate"), "PID.7", "Patient.birthDate", issues); + if (birthDate !== undefined) { + const dtm = v2Date(birthDate); + if (dtm === undefined) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "PID.7", "Patient.birthDate"), + ); + } else { + const pid7 = v2Field([[dtm]]); + if (pid7 !== undefined) fields.set(7, pid7); + } + } + + const gender = readString(at(patient, "gender"), "PID.8", "Patient.gender", issues); + const sex = + gender === undefined + ? undefined + : invertible(GENDER_TO_V2, gender, "PID.8", "Patient.gender", issues); + const pid8 = sex === undefined ? undefined : v2Field([[sex]]); + if (pid8 !== undefined) fields.set(8, pid8); + + const addresses = readComplexes(at(patient, "address"), "PID.11", "Patient.address", issues).map( + (address) => addressComponents(address, issues), + ); + const pid11 = v2Field(addresses); + if (pid11 !== undefined) fields.set(11, pid11); + + return fields; +} + +/** + * Convert a FHIR R4 `Patient` into a **complete** v2 message carrying a `PID` segment, for the + * demographics-only trigger events (`A28`, `A31`, `A29`, ...) that carry a patient with no visit. + * + * The `trigger` argument is **required and never inferred**: no `Patient` element maps to a v2 + * message trigger, so the caller supplies it, and a missing, empty or non-string one returns + * `{ value: undefined }` plus {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} without calling the + * message builder at all. It is used verbatim as MSH-9.2 under the `ADT` message code this shape + * fixes; the caller never supplies the code and it never varies per call. + * + * Lossy by design and never round-trip-safe: a value the inverse of the IG map cannot ground is + * flagged and left absent, never guessed, and a resource of another type is refused outright. + * + * @param resource - The FHIR `Patient` node (build one with `@cosyte/fhir`'s `parseResource`). + * @param trigger - The bare v2 trigger, e.g. `"A28"`. Required; never derived from the resource. + * @param options - Caller-vetted reverse context: assigning authorities, code systems, MSH envelope. + * @example + * ```ts + * import { parseResource } from "@cosyte/fhir"; + * import { toV2Patient } from "@cosyte/transform"; + * + * const { resource } = parseResource('{"resourceType":"Patient","gender":"female"}'); + * const { value, issues } = toV2Patient(resource, "A28"); + * // value.toString() starts "MSH|^~\\&|" and carries "ADT^A28" in MSH-9, then a PID segment + * void value; + * void issues; + * ``` + */ +export function toV2Patient( + resource: FhirNode, + trigger: string, + options: ReverseOptions = {}, +): ReverseResult { + const issues: TransformIssue[] = []; + const triggerOk = hasTrigger(trigger, issues); + const patient = readResource(resource, "Patient", "PID", issues); + if (!triggerOk || patient === undefined) return { value: undefined, issues }; + + flagUnmapped(patient, PATIENT_MAPPED, PATIENT_UNMAPPED, "Patient", "PID", issues); + const ctx = reverseContext(options); + const value = emitMessage("ADT", trigger, "PID", pidFields(patient, ctx, issues), ctx); + return { value, issues }; +} diff --git a/src/reverse/read.ts b/src/reverse/read.ts new file mode 100644 index 0000000..2bc720b --- /dev/null +++ b/src/reverse/read.ts @@ -0,0 +1,154 @@ +/** + * Reading the `@cosyte/fhir` node model on the way **out** of FHIR, never-throw by construction. + * + * The reverse direction is handed nodes it did not build, so every read here answers two questions at + * once: what the element carries, and whether it is the *kind* of node FHIR says it should be. A node + * of the wrong kind (a `HumanName` where a string belongs, a string where a list belongs) is not an + * exception here: it raises {@link ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED} on the caller's issue + * sink and reads as absent, so the whole-repository never-throw guardrail holds for structurally + * malformed input exactly as it does for unmappable input. + * + * @packageDocumentation + */ + +import { + getProperty, + isComplex, + isList, + isPrimitive, + type FhirComplex, + type FhirNode, +} from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; + +/** The nodes an element carries: a list's items, a lone node as one item, or none when absent. */ +function itemsOf(node: FhirNode | undefined): readonly FhirNode[] { + if (node === undefined) return []; + return isList(node) ? node.items : [node]; +} + +/** + * The named property of a complex node, or `undefined` when the element is absent. + * + * @param node - The complex node to read. + * @param name - The FHIR element name. + * @example + * ```ts + * // at(patientNode, "birthDate") -> the birthDate primitive node, or undefined + * ``` + */ +export function at(node: FhirComplex, name: string): FhirNode | undefined { + return getProperty(node, name); +} + +/** + * The string a primitive element carries, or `undefined` when it is absent or empty. A node that is + * not a string primitive is flagged malformed and read as absent. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readString(at(name, "family"), "PID.5.1", "Patient.name.family", issues) -> "Public" + * ``` + */ +export function readString( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (node === undefined) return undefined; + if (!isPrimitive(node) || typeof node.value !== "string") { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + return undefined; + } + return node.value === "" ? undefined : node.value; +} + +/** + * Every string a repeating primitive element carries, in order; non-string items are flagged + * malformed and skipped. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readStrings(at(name, "given"), "PID.5.2", "Patient.name.given", issues) -> ["Jane"] + * ``` + */ +export function readStrings( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): readonly string[] { + const out: string[] = []; + for (const item of itemsOf(node)) { + const value = readString(item, location, fhirPath, issues); + if (value !== undefined) out.push(value); + } + return out; +} + +/** + * Every complex item a repeating element carries; items of another kind are flagged malformed and + * skipped. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readComplexes(at(patient, "name"), "PID.5", "Patient.name", issues) -> [HumanName, ...] + * ``` + */ +export function readComplexes( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): readonly FhirComplex[] { + const out: FhirComplex[] = []; + for (const item of itemsOf(node)) { + if (isComplex(item)) out.push(item); + else issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + } + return out; +} + +/** + * The **exact lexical form** of a numeric element, never routed through a JS `number`: a + * string-backed `decimal` yields its raw text unchanged. A boolean (or a value-absent) primitive is + * flagged malformed. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readNumberText(at(quantity, "value"), "OBX.5", "Observation.valueQuantity.value", issues) -> "120.50" + * ``` + */ +export function readNumberText( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (node === undefined) return undefined; + const value = isPrimitive(node) ? node.value : undefined; + if (value === undefined || typeof value === "boolean") { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + return undefined; + } + return typeof value === "string" ? value : value.raw; +} diff --git a/src/reverse/v2.ts b/src/reverse/v2.ts new file mode 100644 index 0000000..1ae4a6c --- /dev/null +++ b/src/reverse/v2.ts @@ -0,0 +1,170 @@ +/** + * Writing HL7 v2 field content: the small, safe primitives the reverse (FHIR to v2) maps build on. + * + * Two disciplines live here. + * + * **Composite content is structured, never concatenated.** A typed composite (XPN, CX, CWE, XAD) is + * handed to `@cosyte/hl7` as a `RawField` of components, so the serializer owns delimiter escaping: + * a family name of `Do^e` emits as `Do\S\e` and reads back as `Do^e`, where a hand-built `"Do^e"` + * string would have silently become two components. Nothing in this module writes a `^` or a `~`. + * + * **A lexical form v2 cannot carry is refused, never trimmed to fit.** {@link v2Timestamp} and + * {@link v2Number} return `undefined` rather than an approximation, and their callers flag the value + * and leave the v2 field absent. + * + * @packageDocumentation + */ + +import type { RawField } from "@cosyte/hl7"; + +/** One repetition of a field: its components, in order, `undefined` for an absent component. */ +export type V2Components = readonly (string | undefined)[]; + +/** A component with a single subcomponent, or an empty component for absent content. */ +function component(value: string | undefined): { subcomponents: readonly string[] } { + return { subcomponents: value === undefined || value === "" ? [] : [value] }; +} + +/** + * Build a v2 field from its repetitions, each a list of components, or `undefined` when every + * repetition is empty (an empty field is left absent, never emitted as structure). + * + * @param repetitions - The field's repetitions, outermost first. + * @example + * ```ts + * // v2Field([["Public", "Jane"]]) -> a PID-5 field that serializes as `Public^Jane` + * ``` + */ +export function v2Field(repetitions: readonly V2Components[]): RawField | undefined { + const present = repetitions.filter((components) => + components.some((value) => value !== undefined && value !== ""), + ); + if (present.length === 0) return undefined; + return { + isNull: false, + repetitions: present.map((components) => { + // Trailing absent components carry no information on the wire: drop them, keep interior gaps. + let last = components.length; + while (last > 0 && (components[last - 1] === undefined || components[last - 1] === "")) + last--; + return { components: components.slice(0, last).map(component) }; + }), + }; +} + +/** + * Lay fields out in HL7 1-indexed positional order for `addSegment`, filling unmapped positions with + * an empty field. Position 1 is the segment's first field (PID-1, OBX-1, ...). + * + * @param byPosition - The mapped fields, keyed by their 1-based HL7 field position. + * @example + * ```ts + * // segmentFields(new Map([[3, mrnField], [5, nameField]])) -> ["", "", mrnField, "", nameField] + * ``` + */ +export function segmentFields( + byPosition: ReadonlyMap, +): readonly (string | RawField)[] { + const positions = [...byPosition.keys()]; + const highest = positions.length === 0 ? 0 : Math.max(...positions); + const out: (string | RawField)[] = []; + for (let position = 1; position <= highest; position++) { + out.push(byPosition.get(position) ?? ""); + } + return out; +} + +/** FHIR `date`/`dateTime` lexical forms, at every precision R4 permits. */ +const FHIR_DATETIME = + /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(\.\d{1,4})?(Z|[+-]\d{2}:\d{2}))?)?)?$/; + +/** + * FHIR `date`/`dateTime` to a v2 DTM, precision-preserving, or `undefined` when the value has no + * faithful v2 form (an unparseable lexical form, or sub-second precision finer than DTM's four + * digits, which would have to be truncated). `Z` becomes the explicit `+0000` offset v2 writes. + * + * @param value - The FHIR lexical date or dateTime. + * @example + * ```ts + * // v2Timestamp("2026-01-02") -> "20260102" + * // v2Timestamp("2026-01-02T10:15:00-05:00") -> "20260102101500-0500" + * ``` + */ +export function v2Timestamp(value: string): string | undefined { + const parts = FHIR_DATETIME.exec(value); + if (parts === null) return undefined; + const [, year, month, day, hour, minute, second, fraction, zone] = parts; + let out = year ?? ""; + if (month !== undefined) out += month; + if (day !== undefined) out += day; + if (hour === undefined) return out; + out += `${hour}${minute ?? ""}${second ?? ""}${fraction ?? ""}`; + return out + (zone === "Z" ? "+0000" : (zone ?? "").replace(":", "")); +} + +/** + * FHIR `date` to a v2 DTM. A `date` element carrying a time-of-day is outside its own type's value + * domain, so it is refused (`undefined`) rather than quietly re-read as a `dateTime`: reinterpreting + * an out-of-domain value is the coercion the fail-safe rule forbids. + * + * @param value - The FHIR lexical date. + * @example + * ```ts + * // v2Date("1980-01-15") -> "19800115" + * // v2Date("1980-01-15T10:00:00-05:00") -> undefined + * ``` + */ +export function v2Date(value: string): string | undefined { + return value.includes("T") ? undefined : v2Timestamp(value); +} + +/** The FHIR `decimal` lexical forms a v2 NM can carry verbatim: no exponent, no leading `+`. */ +const V2_NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?$/; + +/** + * A FHIR decimal's exact lexical form when v2 NM can carry it unchanged, else `undefined`. An + * exponent form (`1e3`) has no NM representation, and rewriting it would alter the value's lexical + * precision, so it is refused rather than expanded. + * + * @param raw - The decimal's exact lexical text. + * @example + * ```ts + * // v2Number("120.50") -> "120.50" (trailing-zero precision preserved) + * // v2Number("1e3") -> undefined + * ``` + */ +export function v2Number(raw: string): string | undefined { + return V2_NUMBER.test(raw) ? raw : undefined; +} + +/** + * Invert a forward v2-to-FHIR code map, keeping **only** the targets exactly one v2 code maps to. + * + * This is the bijective-subset rule as code. Where several v2 codes are "equivalent to" one FHIR + * concept (Table 0001 `O`/`A`/`N` all map to `other`), the inverse is ambiguous: the target is + * dropped here, and its caller flags `TRANSFORM_CODE_NOT_INVERTIBLE` rather than picking one member + * of the set. + * + * @param forward - A v2 code to FHIR code map, as published by the IG ConceptMap. + * @example + * ```ts + * import { invertCodeMap } from "@cosyte/transform"; + * invertCodeMap({ F: "female", O: "other", A: "other" }); // => { female: "F" } + * ``` + */ +export function invertCodeMap( + forward: Readonly>, +): Readonly> { + const sources = new Map(); + for (const [v2Code, fhirCode] of Object.entries(forward)) { + const seen = sources.get(fhirCode); + if (seen === undefined) sources.set(fhirCode, [v2Code]); + else seen.push(v2Code); + } + const inverse: Record = {}; + for (const [fhirCode, v2Codes] of sources) { + const only = v2Codes[0]; + if (v2Codes.length === 1 && only !== undefined) inverse[fhirCode] = only; + } + return Object.freeze(inverse); +} diff --git a/test/diagnostics/codes-and-issue.test.ts b/test/diagnostics/codes-and-issue.test.ts index a783bee..7d223c3 100644 --- a/test/diagnostics/codes-and-issue.test.ts +++ b/test/diagnostics/codes-and-issue.test.ts @@ -33,6 +33,14 @@ describe("stable code registries", () => { "TRANSFORM_REQUIRED_ELEMENT_UNKNOWN", "TRANSFORM_RESOURCE_INVALID", "TRANSFORM_SEGMENT_ASSEMBLED", + // Phase 7: the reverse direction (additions only). + "TRANSFORM_CODE_NOT_INVERTIBLE", + "TRANSFORM_CODE_SYSTEM_NOT_V2", + "TRANSFORM_MISSING_TRIGGER", + "TRANSFORM_NO_V2_TARGET", + "TRANSFORM_RESOURCE_MALFORMED", + "TRANSFORM_UNSUPPORTED_RESOURCE", + "TRANSFORM_VALUE_NOT_REPRESENTABLE", ].sort(), ); }); diff --git a/test/reverse/observation.test.ts b/test/reverse/observation.test.ts new file mode 100644 index 0000000..86d6b98 --- /dev/null +++ b/test/reverse/observation.test.ts @@ -0,0 +1,312 @@ +/** + * FHIR `Observation` to a v2 ORU-shaped message carrying an OBX: the value-type discrimination, the + * refusals that never emit a confidently wrong result, and the never-throw guardrail. + * + * Every fixture is synthetic; no fixture carries a patient identity at all (this shape's input is an + * `Observation` alone). + */ + +import { describe, it, expect } from "vitest"; +import { parseHL7 } from "@cosyte/hl7"; +import { complex, primitive, parseResource, type FhirComplex } from "@cosyte/fhir"; + +import { toV2Observation, ISSUE_CODES, OBSERVATION_STATUS_TO_V2 } from "../../src/index.js"; + +const LOINC = { system: "http://loinc.org", code: "789-8", display: "Hemoglobin" }; +const UCUM = "http://unitsofmeasure.org"; + +function observation(json: Record): FhirComplex { + return parseResource( + JSON.stringify({ resourceType: "Observation", code: { coding: [LOINC] }, ...json }), + ).resource; +} + +const codes = (result: { issues: readonly { code: string }[] }): string[] => + result.issues.map((i) => i.code); + +describe("toV2Observation: the emitted message", () => { + it("builds a complete ORU message whose OBX carries the code, value, units and status", () => { + const { value, issues } = toV2Observation( + observation({ + status: "final", + effectiveDateTime: "2026-01-02T10:15:00-05:00", + valueQuantity: { value: 120.5, unit: "g/L", system: UCUM, code: "g/L" }, + referenceRange: [{ text: "130-170" }], + interpretation: [ + { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + code: "L", + }, + ], + }, + ], + }), + "R01", + ); + + expect(issues).toEqual([]); + const round = parseHL7(value?.toString() ?? ""); + expect(round.meta.type).toBe("ORU^R01"); + expect(round.get("OBX.2")).toBe("NM"); + expect(round.get("OBX.3.1")).toBe("789-8"); + expect(round.get("OBX.3.2")).toBe("Hemoglobin"); + expect(round.get("OBX.3.3")).toBe("LN"); + expect(round.get("OBX.5")).toBe("120.5"); + expect(round.get("OBX.6.1")).toBe("g/L"); + expect(round.get("OBX.6.3")).toBe("UCUM"); + expect(round.get("OBX.7")).toBe("130-170"); + expect(round.get("OBX.8")).toBe("L"); + expect(round.get("OBX.11")).toBe("F"); + expect(round.get("OBX.14")).toBe("20260102101500-0500"); + }); + + it("carries a magnitude's exact lexical precision, never through a JS number", () => { + // A trailing zero is significant in FHIR and a JS number would drop it: raw JSON in, raw out. + const { value } = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]},"valueQuantity":{"value":120.50}}', + ).resource, + "R01", + ); + expect(parseHL7(value?.toString() ?? "").get("OBX.5")).toBe("120.50"); + }); + + it.each([ + ["a coded value", { valueCodeableConcept: { coding: [LOINC] } }, "CWE", "789-8"], + ["a string value", { valueString: "positive" }, "ST", "positive"], + ["a date-time value", { valueDateTime: "2026-01-02" }, "DTM", "20260102"], + ])("derives OBX-2 from %s rather than assuming a type", (_label, json, type, first) => { + const { value } = toV2Observation(observation({ status: "final", ...json }), "R01"); + const round = parseHL7(value?.toString() ?? ""); + expect(round.get("OBX.2")).toBe(type); + expect(round.get("OBX.5.1")).toBe(first); + }); + + it("writes a comparator-bearing quantity as a structured numeric, never as a bare magnitude", () => { + const { value, issues } = toV2Observation( + observation({ status: "final", valueQuantity: { comparator: "<", value: 5, unit: "mg/L" } }), + "R01", + ); + expect(issues).toEqual([]); + const round = parseHL7(value?.toString() ?? ""); + expect(round.get("OBX.2")).toBe("SN"); + expect(round.get("OBX.5.1")).toBe("<"); + expect(round.get("OBX.5.2")).toBe("5"); + }); +}); + +describe("toV2Observation: the required trigger", () => { + it("refuses an empty trigger without building a message", () => { + const result = toV2Observation(observation({ status: "final" }), ""); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("uses the trigger verbatim under the ORU message code the shape fixes", () => { + const { value } = toV2Observation(observation({ status: "final" }), "R30"); + expect(parseHL7(value?.toString() ?? "").meta.type).toBe("ORU^R30"); + }); +}); + +describe("toV2Observation: refusals that never guess", () => { + it("emits nothing when the observation code has no v2-nameable system", () => { + const result = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://example.org/local","code":"X"}]}}', + ).resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + }); + + it("flags a status whose inverse is ambiguous and leaves OBX-11 absent", () => { + const result = toV2Observation(observation({ status: "entered-in-error" }), "R01"); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBe(undefined); + expect(OBSERVATION_STATUS_TO_V2["entered-in-error"]).toBeUndefined(); + expect(OBSERVATION_STATUS_TO_V2["corrected"]).toBe("C"); + }); + + it.each([ + ["a range value", { valueRange: { low: { value: 1 }, high: { value: 2 } } }], + ["a ratio value", { valueRatio: { numerator: { value: 1 }, denominator: { value: 2 } } }], + ["a boolean value", { valueBoolean: true }], + ])("emits no OBX-5 for %s and flags it", (_label, json) => { + const result = toV2Observation(observation({ status: "final", ...json }), "R01"); + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("OBX.5")).toBe(undefined); + expect(round.get("OBX.2")).toBe(undefined); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged[0]?.v2Location).toBe("OBX.5"); + }); + + it("refuses a magnitude in exponent form rather than rewriting it", () => { + // Written as raw JSON: the exponent form has to survive to the reader as a lexical literal. + const result = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]},"valueQuantity":{"value":1e3}}', + ).resource, + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe(undefined); + }); + + it("flags a unit whose system is not UCUM and keeps only its display text", () => { + const result = toV2Observation( + observation({ + status: "final", + valueQuantity: { + value: 5, + unit: "widgets", + system: "http://example.org/units", + code: "wid", + }, + }), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("OBX.6.1")).toBe(""); + expect(round.get("OBX.6.2")).toBe("widgets"); + }); + + it.each([ + [ + "an abnormal flag the HL70078 map does not carry", + "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + "ZZ", + ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ], + [ + "an interpretation from an unrelated system", + "http://example.org/flags", + "H", + ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, + ], + ])("flags %s and emits no OBX-8", (_label, system, code, expected) => { + const result = toV2Observation( + observation({ status: "final", interpretation: [{ coding: [{ system, code }] }] }), + "R01", + ); + expect(codes(result)).toContain(expected); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.8")).toBe(undefined); + }); + + it("never composes OBX-7 from structured range endpoints", () => { + const result = toV2Observation( + observation({ + status: "final", + referenceRange: [{ low: { value: 130 }, high: { value: 170 } }], + }), + "R01", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged[0]?.v2Location).toBe("OBX.7"); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.7")).toBe(undefined); + }); + + it("flags a second reference range rather than dropping it silently", () => { + const result = toV2Observation( + observation({ status: "final", referenceRange: [{ text: "130-170" }, { text: "120-160" }] }), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE); + }); + + it("flags the subject and encounter rather than assembling a segment for them", () => { + const result = toV2Observation( + observation({ + status: "final", + subject: { reference: "Patient/1" }, + encounter: { reference: "Encounter/1" }, + }), + "R01", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged.map((i) => i.v2Location)).toEqual(["PID", "PV1"]); + expect(parseHL7(result.value?.toString() ?? "").segments("PID")).toHaveLength(0); + }); + + it("refuses a resource of another type", () => { + const result = toV2Observation(parseResource('{"resourceType":"Patient"}').resource, "R01"); + expect(result.value).toBeUndefined(); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Patient"); + }); +}); + +describe("toV2Observation: the never-throw guardrail on malformed input", () => { + it("flags an observation with no code rather than emitting a code-less OBX", () => { + const result = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it("flags a code that is a string where a CodeableConcept belongs, without throwing", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: primitive("789-8") }, + ]), + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it.each([ + ["a quantity magnitude that is a boolean", primitive(true)], + ["a quantity magnitude with no value at all", primitive(undefined)], + ])("flags %s, without throwing", (_label, magnitude) => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "valueQuantity", value: complex([{ name: "value", value: magnitude }]) }, + ]), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe(undefined); + }); + + it("reads a magnitude that arrived as a JSON string, and carries it verbatim", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "valueQuantity", value: complex([{ name: "value", value: primitive("12.50") }]) }, + ]), + "R01", + ); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe("12.50"); + }); + + it("flags a status that is a boolean, without throwing", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "status", value: primitive(true) }, + ]), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it("carries CodeableConcept.text into CWE.9 when no coding resolves", () => { + const { value } = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final","code":{"text":"Hemoglobin"}}') + .resource, + "R01", + ); + expect(parseHL7(value?.toString() ?? "").get("OBX.3.9")).toBe("Hemoglobin"); + }); +}); diff --git a/test/reverse/patient.test.ts b/test/reverse/patient.test.ts new file mode 100644 index 0000000..cb59a69 --- /dev/null +++ b/test/reverse/patient.test.ts @@ -0,0 +1,263 @@ +/** + * FHIR `Patient` to a v2 ADT-shaped message carrying a PID: the happy path, the fail-safe refusals, + * and the never-throw guardrail on structurally malformed input. + * + * Every fixture is synthetic: the person tokens, MRN and date of birth are the ones declared in + * `scripts/phi-allow-list.txt`. + */ + +import { describe, it, expect } from "vitest"; +import { parseHL7 } from "@cosyte/hl7"; +import { complex, list, primitive, parseResource, type FhirComplex } from "@cosyte/fhir"; + +import { toV2Patient, ISSUE_CODES, GENDER_TO_V2, NAME_USE_TO_V2 } from "../../src/index.js"; + +function patient(json: Record): FhirComplex { + return parseResource(JSON.stringify({ resourceType: "Patient", ...json })).resource; +} + +const codes = (result: { issues: readonly { code: string }[] }): string[] => + result.issues.map((i) => i.code); + +describe("toV2Patient: the emitted message", () => { + it("builds a complete ADT message whose MSH-9 carries the caller's trigger verbatim", () => { + const { value, issues } = toV2Patient( + patient({ + identifier: [ + { + value: "MRN1", + type: { + coding: [{ system: "http://terminology.hl7.org/CodeSystem/v2-0203", code: "MR" }], + }, + }, + ], + name: [{ use: "maiden", family: "Public", given: ["Jane", "Q"] }], + birthDate: "1980-01-15", + gender: "female", + address: [ + { use: "home", line: ["123 Main St"], city: "Boston", state: "MA", postalCode: "02101" }, + ], + }), + "A28", + { assigningAuthorities: { "urn:oid:1.2.3": "HOSP" } }, + ); + + expect(issues).toEqual([]); + const wire = value?.toString() ?? ""; + expect(wire.startsWith("MSH|^~\\&|")).toBe(true); + + // Parses back under the parser that owns the wire format: no fatal error, MSH-led, fields intact. + const round = parseHL7(wire); + expect(round.meta.type).toBe("ADT^A28"); + expect(round.get("PID.3.1")).toBe("MRN1"); + expect(round.get("PID.3.5")).toBe("MR"); + expect(round.get("PID.5.1")).toBe("Public"); + expect(round.get("PID.5.2")).toBe("Jane"); + expect(round.get("PID.5.3")).toBe("Q"); + expect(round.get("PID.5.7")).toBe("M"); + expect(round.get("PID.7")).toBe("19800115"); + expect(round.get("PID.8")).toBe("F"); + expect(round.get("PID.11.1")).toBe("123 Main St"); + expect(round.get("PID.11.3")).toBe("Boston"); + expect(round.get("PID.11.5")).toBe("02101"); + expect(round.get("PID.11.7")).toBe("H"); + }); + + it("carries the MSH envelope the caller supplies, and repeats a repeating field", () => { + const { value } = toV2Patient( + patient({ name: [{ family: "Public" }, { family: "Doe" }] }), + "A31", + { envelope: { sendingApp: "EHR", sendingFacility: "MAIN", controlId: "MSGID1" } }, + ); + const round = parseHL7(value?.toString() ?? ""); + expect(round.meta.sendingApp).toBe("EHR"); + expect(round.meta.controlId).toBe("MSGID1"); + expect(round.get("PID.5.1")).toBe("Public"); + expect(round.get("PID.5[1].1")).toBe("Doe"); + }); + + it("escapes delimiter-bearing content instead of splitting a composite", () => { + const { value } = toV2Patient(patient({ name: [{ family: "Do^e|Public" }] }), "A28"); + const wire = value?.toString() ?? ""; + expect(wire).toContain("Do\\S\\e\\F\\Public"); + expect(parseHL7(wire).get("PID.5.1")).toBe("Do^e|Public"); + }); + + it("emits no message when nothing in the resource maps to a PID field", () => { + const result = toV2Patient(patient({ active: true }), "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + }); + + it("seeds the assigning authority only from the caller, never from the system URI", () => { + const withSeed = toV2Patient( + patient({ identifier: [{ value: "MRN1", system: "urn:oid:1.2.3" }] }), + "A28", + { assigningAuthorities: { "urn:oid:1.2.3": "HOSP" } }, + ); + expect(parseHL7(withSeed.value?.toString() ?? "").get("PID.3.4")).toBe("HOSP"); + expect(withSeed.issues).toEqual([]); + + const unseeded = toV2Patient( + patient({ identifier: [{ value: "MRN1", system: "urn:oid:1.2.3" }] }), + "A28", + ); + expect(parseHL7(unseeded.value?.toString() ?? "").get("PID.3.4")).toBe(undefined); + expect(codes(unseeded)).toEqual([ISSUE_CODES.TRANSFORM_NO_V2_TARGET]); + }); +}); + +describe("toV2Patient: the required trigger", () => { + it.each([ + ["empty", ""], + ["whitespace only", " "], + ])("refuses a %s trigger without building a message", (_label, trigger) => { + const result = toV2Patient(patient({ gender: "female" }), trigger); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("refuses a non-string trigger from an untyped caller without throwing", () => { + // Deliberate: the parameter is typed `string`, and a JavaScript caller can still pass anything. + const result = toV2Patient(patient({ gender: "female" }), undefined as unknown as string); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("uses the trigger verbatim, never padded or normalized", () => { + const { value } = toV2Patient(patient({ gender: "male" }), "a08"); + expect(parseHL7(value?.toString() ?? "").meta.type).toBe("ADT^a08"); + }); +}); + +describe("toV2Patient: refusals that never guess", () => { + it("refuses a resource of another supported type and names it", () => { + const result = toV2Patient( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "A28", + ); + expect(result.value).toBeUndefined(); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Observation"); + }); + + it("reports an unrecognized resource type generically, so no input text reaches a diagnostic", () => { + const result = toV2Patient(parseResource('{"resourceType":"Zzz9"}').resource, "A28"); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Resource"); + }); + + it.each([ + ["a gender with an ambiguous inverse", { gender: "other" }, "PID.8"], + [ + "a name use with an ambiguous inverse", + { name: [{ use: "official", family: "Public" }] }, + "XPN.7", + ], + [ + "an address use with an ambiguous inverse", + { address: [{ use: "work", city: "Boston" }] }, + "XAD.7", + ], + [ + "an address type, which XAD.7 cannot carry alongside use", + { address: [{ type: "postal", city: "Boston" }] }, + "XAD.7", + ], + ])("flags %s rather than picking one v2 code", (_label, json, location) => { + const result = toV2Patient(patient(json), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe(location); + expect( + parseHL7(result.value?.toString() ?? "MSH|^~\\&|||||20260101||ADT^A28|1|P|2.5").get("PID.8"), + ).toBe(undefined); + }); + + it("keeps the invertible rows of the same maps", () => { + expect(GENDER_TO_V2["female"]).toBe("F"); + expect(GENDER_TO_V2["other"]).toBeUndefined(); + expect(NAME_USE_TO_V2["maiden"]).toBe("M"); + expect(NAME_USE_TO_V2["official"]).toBeUndefined(); + }); + + it.each([ + ["a third given name", { name: [{ family: "Public", given: ["Jane", "Q", "X"] }] }, "XPN.3"], + ["a second prefix", { name: [{ family: "Public", prefix: ["Dr", "Prof"] }] }, "XPN.5"], + ["a second suffix", { name: [{ family: "Public", suffix: ["Jr", "III"] }] }, "XPN.4"], + ["a third address line", { address: [{ line: ["123 Main St", "Apt 4", "1 St"] }] }, "XAD.2"], + ["a birth date carrying a time", { birthDate: "1980-01-15T10:00:00-05:00" }, "PID.7"], + ])("flags %s rather than truncating it", (_label, json, location) => { + const result = toV2Patient(patient(json), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + ); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe(location); + }); + + it("flags an identifier type coding from an unrelated table", () => { + const result = toV2Patient( + patient({ + identifier: [ + { value: "MRN1", type: { coding: [{ system: "http://example.org/local", code: "MR" }] } }, + ], + }), + "A28", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + expect(parseHL7(result.value?.toString() ?? "").get("PID.3.5")).toBe(undefined); + }); + + it("flags a populated element with no v2 field in this map", () => { + const result = toV2Patient( + patient({ gender: "female", telecom: [{ value: "555-1234" }] }), + "A28", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe("PID.13"); + expect(flagged[0]?.fhirPath).toBe("Patient.telecom"); + }); +}); + +describe("toV2Patient: the never-throw guardrail on malformed input", () => { + it.each([ + ["a node that is not a resource", primitive("Patient")], + ["a resource with no resourceType", complex([{ name: "gender", value: primitive("female") }])], + ])("returns a typed diagnostic for %s", (_label, node) => { + const result = toV2Patient(node, "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED]); + }); + + it.each([ + [ + "a name that is a string where a HumanName belongs", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "name", value: primitive("Public") }, + ]), + ], + [ + "a birthDate that is a boolean", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "birthDate", value: primitive(true) }, + ]), + ], + [ + "a given name list holding a complex", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "name", value: list([complex([{ name: "given", value: list([complex([])]) }])]) }, + ]), + ], + ])("flags %s without throwing", (_label, node) => { + const result = toV2Patient(node, "A28"); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); +}); diff --git a/test/reverse/property.test.ts b/test/reverse/property.test.ts new file mode 100644 index 0000000..5707d86 --- /dev/null +++ b/test/reverse/property.test.ts @@ -0,0 +1,185 @@ +/** + * Property + fuzz coverage over the **reverse boundary**. For arbitrary (including hostile) FHIR + * resources and triggers, `toV2Patient` / `toV2Observation` must: + * 1. **never throw** (the fail-safe rule, in the other direction); + * 2. raise only **registered**, **value-free** issue codes (a sentinel threaded through every + * mapped value, and through `resourceType` itself, must never reach the diagnostic channel); + * 3. emit a **complete message** that `@cosyte/hl7`'s own parser accepts without a fatal error + * (never a bare segment: a segment with no MSH is not parseable HL7); + * 4. carry the caller's trigger **verbatim** in MSH-9, under the message code the shape fixes; and + * 5. build **no message at all** when the required trigger is missing, empty, or not bare. + * + * (3) is a parses-back check, not a round-trip claim: nothing here asserts that the emitted message + * equals, or transforms back to, any original. + */ + +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { parseHL7, Hl7ParseError } from "@cosyte/hl7"; +import { parseResource } from "@cosyte/fhir"; + +import { + toV2Patient, + toV2Observation, + ISSUE_CODES, + ISSUE_REGISTRY, + type ReverseResult, +} from "../../src/index.js"; + +const SENTINEL = "PHIZZ"; +const registeredCodes = new Set(Object.values(ISSUE_CODES)); +const numRuns = Number(process.env["FUZZ_RUNS"] ?? "300"); + +/** A token that always carries the leak sentinel, and may carry HL7 delimiters. */ +const token = fc.stringMatching(/^[A-Za-z0-9 |^~\\&]{0,8}$/).map((s) => SENTINEL + s); +const optToken = fc.option(token, { nil: undefined }); +const trigger = fc.constantFrom("A01", "A08", "A28", "A31", "R01", "R30", "a08", " A01 ", "^", "|"); +const gender = fc.constantFrom("female", "male", "other", "unknown", "PHIZZ", ""); +const status = fc.constantFrom("final", "corrected", "entered-in-error", "registered", "PHIZZ", ""); + +/** Assert the invariants that hold for every reverse result, whatever it was handed. */ +function assertResult(result: ReverseResult, messageCode: string, trig: string): void { + // (2) registered + value-free + expect(JSON.stringify(result.issues)).not.toContain(SENTINEL); + for (const raised of result.issues) { + expect(registeredCodes.has(raised.code)).toBe(true); + expect(raised.v2Location.length).toBeGreaterThan(0); + expect(raised.message).toBe(ISSUE_REGISTRY[raised.code].message); + } + if (result.value === undefined) return; + // (3) a complete message the parser accepts, never a bare segment + const wire = result.value.toString(); + expect(wire.startsWith("MSH|")).toBe(true); + const round = parseHL7(wire); + // (4) the trigger, verbatim, under the shape's own message code + expect(round.meta.type).toBe(`${messageCode}^${trig}`); +} + +describe("reverse boundary: fail-safe, value-free, parses back, trigger verbatim", () => { + it("never throws and holds every invariant over arbitrary Patient resources", () => { + const arb = fc.record({ + trig: trigger, + family: optToken, + given: fc.array(token, { maxLength: 3 }), + mrn: optToken, + system: fc.option(fc.constantFrom("urn:oid:1.2.3", "http://example.org/x"), { + nil: undefined, + }), + sex: gender, + use: fc.constantFrom("official", "maiden", "temp", "PHIZZ", ""), + birthDate: fc.constantFrom("1980-01-15", "1980-01", "1980", "PHIZZ", "1980-01-15T10:00:00Z"), + city: optToken, + }); + fc.assert( + fc.property(arb, (p) => { + const resource = parseResource( + JSON.stringify({ + resourceType: "Patient", + identifier: p.mrn === undefined ? undefined : [{ value: p.mrn, system: p.system }], + name: [{ use: p.use, family: p.family, given: p.given }], + birthDate: p.birthDate, + gender: p.sex, + address: [{ city: p.city }], + }), + ).resource; + let result: ReverseResult; + try { + result = toV2Patient(resource, p.trig, { + assigningAuthorities: { "urn:oid:1.2.3": "HOSP" }, + }); + } catch (err) { + throw new Error("toV2Patient threw (reverse fail-safe violated)", { cause: err }); + } + assertResult(result, "ADT", p.trig); + }), + { numRuns }, + ); + }); + + it("never throws and holds every invariant over arbitrary Observation resources", () => { + const arb = fc.record({ + trig: trigger, + code: optToken, + system: fc.constantFrom("http://loinc.org", "http://example.org/local", ""), + state: status, + magnitude: fc.constantFrom(120.5, 0, -3, 1), + unit: optToken, + text: optToken, + flag: fc.constantFrom("H", "ZZ", "PHIZZ", ""), + effective: fc.constantFrom("2026-01-02T10:15:00-05:00", "2026-01-02", "PHIZZ"), + }); + fc.assert( + fc.property(arb, fc.boolean(), (p, coded) => { + const value = coded + ? { valueCodeableConcept: { coding: [{ system: p.system, code: p.code }] } } + : { valueQuantity: { value: p.magnitude, unit: p.unit } }; + const resource = parseResource( + JSON.stringify({ + resourceType: "Observation", + status: p.state, + code: { coding: [{ system: p.system, code: p.code }], text: p.text }, + effectiveDateTime: p.effective, + interpretation: [ + { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + code: p.flag, + }, + ], + }, + ], + ...value, + }), + ).resource; + let result: ReverseResult; + try { + result = toV2Observation(resource, p.trig); + } catch (err) { + throw new Error("toV2Observation threw (reverse fail-safe violated)", { cause: err }); + } + assertResult(result, "ORU", p.trig); + }), + { numRuns }, + ); + }); + + it("never throws, and never emits, on a hostile resourceType or an unusable trigger", () => { + const hostileType = fc.stringMatching(/^[A-Za-z0-9]{0,12}$/).map((s) => SENTINEL + s); + fc.assert( + fc.property(hostileType, fc.constantFrom("", " ", "A^28", "A 28", "A28"), (type, trig) => { + const resource = parseResource( + JSON.stringify({ resourceType: type, gender: "female" }), + ).resource; + const result = toV2Patient(resource, trig); + // A rejected type never reaches a diagnostic: it is reported as the generic Resource. + expect(JSON.stringify(result.issues)).not.toContain(SENTINEL); + for (const raised of result.issues) expect(registeredCodes.has(raised.code)).toBe(true); + expect(result.value).toBeUndefined(); + if (trig.trim() === "") { + expect(result.issues.map((i) => i.code)).toContain(ISSUE_CODES.TRANSFORM_MISSING_TRIGGER); + } + }), + { numRuns }, + ); + }); + + it("refuses a trigger that is not bare, rather than writing something else into MSH-9", () => { + for (const trig of ["A^28", "A 28", "A|28", "A~28"]) { + const result = toV2Patient( + parseResource('{"resourceType":"Patient","gender":"female"}').resource, + trig, + ); + expect(result.value).toBeUndefined(); + expect(result.issues.map((i) => i.code)).toEqual([ + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + ]); + } + }); + + it("emits nothing a v2 parser would fatally reject", () => { + // The one shape that is guaranteed to fail: a bare segment with no MSH. Asserted here so the + // parses-back property above is known to have teeth. + expect(() => parseHL7("PID|||MRN1||Public^Jane")).toThrow(Hl7ParseError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4e912e7..f0343ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ import { cosyteVitest } from "@cosyte/vitest-config"; * foundation. Add directories here (e.g. "messages", "profiles") as later phases land. */ export default cosyteVitest({ - coverageDirs: ["datatypes", "diagnostics", "terminology", "messages"], + coverageDirs: ["datatypes", "diagnostics", "terminology", "messages", "reverse"], test: { globals: false, environment: "node", From e0847a3074b22284a82a3fe7c576028ff232cc2a Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 14 Aug 2026 01:11:00 +0000 Subject: [PATCH 2/2] fix(reverse): declare every absent v2-required field and the decline to emit (S0013-transform-advance) The reverse direction satisfied half of its own rule. A v2-required field the FHIR resource gave no source for was left ABSENT, which is right, and raised NOTHING, which is not: a Patient with neither identifier nor name emitted an ADT whose PID had no PID-3 and no PID-5 with an empty issues array, an Observation with no status emitted an OBX with no OBX-11 the same way, and a resource that grounded no field at all returned value undefined with issues [], which a caller cannot tell apart from a successful empty conversion. flagUnmapped iterates the elements a resource CARRIES, so a wholly absent element could never raise a thing. Two issue codes, additions only, no key renamed or removed: - TRANSFORM_V2_REQUIRED_FIELD_ABSENT, once per required field that ends up absent from an emitted segment, carrying the v2 location and the FHIR path it would have come from. - TRANSFORM_NO_V2_MESSAGE_EMITTED, when nothing grounded a single field and no message is built. Distinct from the refusals that name their own cause (absent trigger, unsupported resource, malformed resource), which still return theirs. DIAGNOSTICS ONLY: no emitted segment content changes. Both suites pin the exact wire byte for byte for a shape whose required field is absent, so no placeholder, empty component, reordering or extra field can ride along under this contract. test/reverse/patient.test.ts asserted issues was empty on a resource with no name, which pinned the silence; that assertion is retired and now asserts the PID-5 diagnostic instead. Each shape declares its required rows (PID_REQUIRED, OBX_REQUIRED). The usage cells behind them are ASSERTED, NOT EXTRACTED, and the banner above RequiredV2Field says so: this pass had no network egress and could not open Chapter 3 3.4.2 or Chapter 7 7.4.2. So the rule the field-number corroboration left behind is kept literally, an item number appears only where this repository already extracted one (PID-3 00106, PID-5 00108) and the OBX rows carry none. OBX-2, OBX-4 and OBX-5 are conditional rather than required and are deliberately not declared. Gates: typecheck, lint, format:check, test (504), test:coverage (reverse 97.8 / 93.02 / 100 / 97.81, gate 90), phi-scan, check:no-emdash, check:no-internal-refs, check:agent-notes, build + attw, all green. No gate loosened. --- .changeset/quiet-owls-declare.md | 11 +++ CLAUDE.md | 13 ++- README.md | 10 ++ docs-content/guides-overview.md | 32 +++++++ docs-content/troubleshooting.md | 9 ++ documentation/agent-notes.md | 26 +++++ src/diagnostics/codes.ts | 14 +++ src/diagnostics/issue.ts | 12 +++ src/reverse/message.ts | 117 +++++++++++++++++++++-- src/reverse/observation.ts | 36 ++++++- src/reverse/patient.ts | 33 ++++++- test/diagnostics/codes-and-issue.test.ts | 2 + test/reverse/observation.test.ts | 55 +++++++++++ test/reverse/patient.test.ts | 78 ++++++++++++++- 14 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 .changeset/quiet-owls-declare.md diff --git a/.changeset/quiet-owls-declare.md b/.changeset/quiet-owls-declare.md new file mode 100644 index 0000000..31974ca --- /dev/null +++ b/.changeset/quiet-owls-declare.md @@ -0,0 +1,11 @@ +--- +"@cosyte/transform": patch +--- + +Declare, rather than merely leave, what the reverse (FHIR to v2) direction cannot supply. Two issue codes are added, additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed, and no emitted segment content changes. + +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` is raised once per v2-required field that ends up absent from an emitted segment because the FHIR resource carried no source this map could ground it from. Until now that absence was silent: a `Patient` with neither `identifier` nor `name` emitted an `ADT` whose `PID` carried neither PID-3 (Patient Identifier List) nor PID-5 (Patient Name), and an `Observation` with no `status` emitted an `ORU` whose `OBX` carried no OBX-11 (Observation Result Status), both with an empty `issues` array. The field is still left absent, exactly as before, because a placeholder written to satisfy v2 structure would be a fabricated clinical value; what changes is that the receiver is no longer the first to find out. + +`TRANSFORM_NO_V2_MESSAGE_EMITTED` is raised when a conversion produces no message at all, because nothing in the resource grounded a single field of the target segment. That case previously returned `{ value: undefined, issues: [] }`, which a caller could not tell apart from a successful empty conversion. It is distinct from the refusals that name their own cause (an absent trigger, an unsupported resource type, a structurally malformed resource), each of which still returns its own code. + +Both codes carry an `error` severity, a v2 location and a FHIR path, and no value, in keeping with the value-free diagnostic contract. Callers that branch on severity will now see these two, which is the intent: an emitted message missing a v2-required field is not conformant, and the diagnostic channel is where that is said. diff --git a/CLAUDE.md b/CLAUDE.md index 8cf3eaf..b13a916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,10 +37,15 @@ as a trap is clinical-safety content. argument on every entry point** and is never inferred: no FHIR resource carries one. **▶ THE IG PUBLISHES NO FHIR-TO-V2 MAP**, so a many-to-one forward row has no usable inverse and is refused, never resolved to its most likely source code; and **round-trip is asserted only as - "parses back", never as "equals"**. The `Patient` + `Encounter` visit-carrying ADT is **deferred, - not dropped**: the vendored parser exports no ADT assembly entry point (measured, zero occurrences - in its `dist/`), and hand-assembling PID + PV1 here would invert the tier split. Every measurement, - the refusal set, and the deferral: + "parses back", never as "equals"**. **▶ AND ABSENT IS NOT THE SAME AS SILENT**: a v2-REQUIRED field + the resource gives no source for (PID-3, PID-5, OBX-11) stays absent and RAISES + `TRANSFORM_V2_REQUIRED_FIELD_ABSENT`, and a conversion that grounds no field at all raises + `TRANSFORM_NO_V2_MESSAGE_EMITTED` instead of returning an empty success. **The usage cells behind + those rows are asserted, NOT extracted** (the pass that wrote them had no network egress), so + re-extract before trusting or widening them. The `Patient` + `Encounter` visit-carrying ADT is + **deferred, not dropped**: the vendored parser exports no ADT assembly entry point (measured, zero + occurrences in its `dist/`), and hand-assembling PID + PV1 here would invert the tier split. Every + measurement, the refusal set, and the deferral: `documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. Phase **8 (profiles)** and deeper terminology remain deferred. - **Never quote a version here.** This line read "not yet published to npm" for several releases diff --git a/README.md b/README.md index e2ba370..a8f2340 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,16 @@ produced them. An element with no v2 field in this narrow map is flagged written under a borrowed table (`TRANSFORM_CODE_SYSTEM_NOT_V2`). Nothing here reconstructs the message a resource came from, and nothing claims to. +**What v2 requires but your resource does not carry is left absent, and said out loud.** A `PID` +needs PID-3 (Patient Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation +Result Status). A resource that gives no source for one of them still gets a message with that field +absent, never a placeholder invented to satisfy v2 structure, and one +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` diagnostic per field, carrying the v2 location and the FHIR path +it would have come from. A resource that grounds no field of the target segment at all returns no +message and one `TRANSFORM_NO_V2_MESSAGE_EMITTED`, so an empty-handed conversion is never mistaken +for a successful one. Both are `error` severity: an emitted message missing a field v2 requires is +not conformant, and this is where you find that out rather than at the receiver. + ## License MIT © Cosyte diff --git a/docs-content/guides-overview.md b/docs-content/guides-overview.md index b1a95f2..70d54ff 100644 --- a/docs-content/guides-overview.md +++ b/docs-content/guides-overview.md @@ -77,6 +77,38 @@ issues[0]?.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE; // => true issues[0]?.v2Location; // => "PID.8" ``` +The same rule covers what v2 requires and your resource does not carry. A `PID` needs PID-3 (Patient +Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation Result Status). None of +them has a safe default, so the field is left absent rather than padded with an invented value, and +its absence is reported: check the issues before you send the message. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","identifier":[{"value":"MRN1"}]}'); +const { value, issues } = toV2Patient(resource, "A28"); + +// The message is emitted, with the required name field absent rather than fabricated. +value?.toString().includes("PID|||MRN1"); // => true +issues[0]?.code === ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT; // => true +issues[0]?.v2Location; // => "PID.5" +``` + +If nothing in the resource grounds a single field of the target segment, there is no message at all, +and that is reported too rather than returned as an empty success. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient"}'); +const { value, issues } = toV2Patient(resource, "A28"); + +value; // => undefined +issues[0]?.code === ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED; // => true +``` + ## Planned guides Not yet written: assembling a full `Patient`/`Encounter`/`Observation` graph from a message, diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index f7516ef..23f6987 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -61,6 +61,15 @@ resource values; those carry PHI. (`TRANSFORM_NO_V2_TARGET`), and a value v2 cannot carry unchanged is left out (`TRANSFORM_VALUE_NOT_REPRESENTABLE`). Emitting a `Patient` **and** an `Encounter` together as a visit-carrying ADT is not implemented. +- **An emitted message can be missing a field v2 requires, and it tells you so.** PID-3 (Patient + Identifier List), PID-5 (Patient Name) and OBX-11 (Observation Result Status) are required fields + with no safe default: a resource that carries no source for one leaves it absent, never a + fabricated placeholder, and raises one `TRANSFORM_V2_REQUIRED_FIELD_ABSENT` per field naming the v2 + location and the FHIR path it would have come from. Supply the missing element on the resource, or + repair the message before you send it. If nothing in the resource grounds any field of the target + segment, there is no message to repair: the call returns `value: undefined` and one + `TRANSFORM_NO_V2_MESSAGE_EMITTED`, which is how an empty-handed conversion is told apart from a + successful one. - **Thin-IG-single scope**: each family covers the single trigger the IG maps and the resource-internal fields; references to resources this tier does not yet build (Immunization performer/manufacturer/location, Appointment practitioner/location participants, DocumentReference diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 19abdea..5ab156d 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -105,6 +105,32 @@ turn into further MSH-9 components) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE`, not be written verbatim into MSH-9.2 and trimming it would emit something the caller did not ask for. Both were found by the fuzz suite, not by reading. +**▶ ABSENT IS THE RIGHT WIRE. SILENT IS NOT, AND THAT DISTINCTION SHIPPED BROKEN ONCE.** The first +cut of this direction satisfied half of its own rule: a `Patient` carrying neither `identifier` nor +`name` emitted an `ADT` whose `PID` had no PID-3 and no PID-5, an `Observation` with no `status` +emitted an `OBX` with no OBX-11, and both returned an **empty `issues` array**. Nothing was +fabricated, which was the half that held. But `flagUnmapped` iterates the elements a resource +**carries**, so a wholly ABSENT element could raise nothing at all, and a wholly empty conversion +returned `{ value: undefined, issues: [] }`, indistinguishable from a successful empty one. The fix +is diagnostics-only and the wire is pinned byte for byte in both suites: each shape now declares its +required fields (`PID_REQUIRED`, `OBX_REQUIRED`), every one that no mapped content reached raises +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT`, and an empty field map raises +`TRANSFORM_NO_V2_MESSAGE_EMITTED` instead of nothing. **Do not "fix" an absent required field by +supplying a default**: an OBX-11 defaulted to `F` reports a result as final that the sender never +called final. + +**▶ AND THE USAGE CELLS BEHIND THOSE ROWS ARE ASSERTED, NOT EXTRACTED. THAT IS A DISCLOSED GAP, NOT A +CLEARED ONE.** Each row claims one cell of a published v2.5.1 segment attribute table (the OPT column +reading `R`), from Chapter 3 §3.4.2 for PID and Chapter 7 §7.4.2 for OBX. The pass that wrote them +**could not open either publication** (no network egress), so it asserted the four cells rather than +reading them out, and said so in the banner above `RequiredV2Field` in `src/reverse/message.ts`. +The rule the field-number corroboration left behind is kept literally: **an item number appears only +where this repository already extracted one**, so PID-3 carries `00106` and PID-5 carries `00108` +(from the dated Chapter 3 extraction in `test/scripts/phi-scan.test.ts`) and the two OBX rows carry +none at all. **Re-extract all four the next time a reader has the tables open**, and re-extract +before adding a fifth: a wrong usage cell is a false diagnostic on a clinical field, and unlike a +wrong field number it fires on every conversion rather than once. + **Composites are structured, never concatenated.** Field content goes to `addSegment` as a `RawField` of components, so the serializer owns escaping: a family name of `Do^e|Public` emits as `Do\S\e\F\Public` and reads back identically, where a hand-joined `"Do^e"` string would have become diff --git a/src/diagnostics/codes.ts b/src/diagnostics/codes.ts index d63b839..14489af 100644 --- a/src/diagnostics/codes.ts +++ b/src/diagnostics/codes.ts @@ -115,6 +115,20 @@ export const ISSUE_CODES = { * the coding cannot be written into a v2 coded field. It is flagged rather than emitted with no * table context or with a code from an unrelated table. */ TRANSFORM_CODE_SYSTEM_NOT_V2: "TRANSFORM_CODE_SYSTEM_NOT_V2", + /** A v2 field the segment's own attribute table marks **required** (usage `R`) is absent from an + * emitted segment, because the FHIR resource carried no source this reverse map could ground it + * from, or the source it carried had no faithful v2 form. The field is left absent per v2 + * optionality rules and **declared here**: a placeholder is never written to satisfy v2 structure, + * and the receiver is never left to discover the gap. Distinct from + * {@link ISSUE_CODES.TRANSFORM_REQUIRED_ELEMENT_UNKNOWN}, which is the forward direction's + * FHIR-required element. */ + TRANSFORM_V2_REQUIRED_FIELD_ABSENT: "TRANSFORM_V2_REQUIRED_FIELD_ABSENT", + /** A reverse (FHIR to v2) conversion produced **no message at all**: nothing in the resource + * grounded a single field of the target segment, and an empty segment is never emitted. Raised so + * that an empty-handed conversion is never indistinguishable from a successful one, and separate + * from the refusals that name their own cause (an absent trigger, an unsupported resource type, a + * structurally malformed resource). */ + TRANSFORM_NO_V2_MESSAGE_EMITTED: "TRANSFORM_NO_V2_MESSAGE_EMITTED", } as const; /** A value from {@link ISSUE_CODES}: the type consumers narrow `issue.code` against. */ diff --git a/src/diagnostics/issue.ts b/src/diagnostics/issue.ts index 0919a8b..50596fd 100644 --- a/src/diagnostics/issue.ts +++ b/src/diagnostics/issue.ts @@ -171,6 +171,18 @@ export const ISSUE_REGISTRY: Readonly> = Object.fre message: "coding system has no v2 coding-system mnemonic here; coding flagged rather than written with no table context or from an unrelated table.", }, + [ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]: { + severity: "error", + fhirIssueType: "required", + message: + "a v2-required field is absent from the emitted segment because the resource carried no source this map could ground it from; left absent per v2 optionality rules and declared here, never filled with a placeholder.", + }, + [ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED]: { + severity: "error", + fhirIssueType: "processing", + message: + "no v2 message was emitted: nothing in the resource grounded a single field of the target segment, and an empty segment is never emitted.", + }, }); /** diff --git a/src/reverse/message.ts b/src/reverse/message.ts index c4ba6ee..035766d 100644 --- a/src/reverse/message.ts +++ b/src/reverse/message.ts @@ -13,6 +13,15 @@ * complete message through `buildMessage` and appends its mapped segment to it. The trigger is used * verbatim as the trailing component of the fixed message code the shape itself owns. * + * **What is missing is declared, not merely missing.** A FHIR resource carrying no source for a v2 + * field the segment's own attribute table marks *required* leaves that field absent, because the + * alternative is inventing content for a clinical reader. Absent is the right wire; **silent** is + * not, so each shape names its required fields ({@link ReverseShape.required}) and every one of them + * that ends up unsourced raises {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}. When nothing + * at all grounded a field, there is no segment to be missing from and no message is built: + * {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED} declares that outcome on its own, so an + * empty-handed conversion never looks like a successful one. + * * @packageDocumentation */ @@ -178,32 +187,120 @@ export function flagUnmapped( } } +// ▶ EVERY `RequiredV2Field` ROW A SHAPE DECLARES CLAIMS ONE CELL OF A PUBLISHED HL7 +// v2.5.1 SEGMENT ATTRIBUTE TABLE: the OPT (usage) column reading `R`. The tables +// are Chapter 3 §3.4.2 (PID) and Chapter 7 §7.4.2 (OBX), the same standard text +// and the same second, version-pinned publication that `scripts/phi-scan.ts` cites +// for its field numbers, and the VERSION IS LOAD-BEARING here for the same reason +// it is there: a later v2 reads some of these cells differently, so grounding +// against the wrong version's table yields a confident wrong answer, not an error. +// +// ▶ AND THE USAGE CELL WAS NOT EXTRACTED BY THE PASS THAT WROTE THESE ROWS, WHICH +// IS SAID HERE RATHER THAN LEFT TO BE DISCOVERED. That pass ran with no network +// egress and could not open either publication, so the four rows below are +// ASSERTED from the attribute tables rather than read out of them. This repository +// has already measured what that costs once: a `PV1-7` item number written from +// recall, right by luck, and invisible either way. So the rule that work left +// behind is kept literally here too: AN ITEM NUMBER IS WRITTEN ONLY WHERE THIS +// REPOSITORY HAS ALREADY EXTRACTED ONE. `PID-3` (`00106`) and `PID-5` (`00108`) +// carry theirs, from the Chapter 3 extraction dated 2026-08-08 in +// `test/scripts/phi-scan.test.ts`; the OBX rows carry NONE, because nothing here +// has ever extracted one. +// +// ▶ RE-EXTRACT BEFORE ADDING A ROW, AND RE-EXTRACT THESE FOUR THE NEXT TIME A +// READER HAS THE TABLES OPEN. A wrong usage cell is a FALSE DIAGNOSTIC ON A +// CLINICAL FIELD, the same harm class the field-number corroboration exists for, +// and unlike a wrong field number it fires on every conversion rather than once. + +/** + * One v2 field the segment's own attribute table marks **required**, paired with the FHIR element + * this reverse map would have sourced it from. A required field with no source is left absent (v2 + * optionality is a receiver-side contract, and a placeholder would be a fabricated clinical value), + * and declared: absence is the right wire, silence is not. + * + * @example + * ```ts + * // { position: 3, location: "PID.3", fhirPath: "Patient.identifier" } + * ``` + */ +export interface RequiredV2Field { + /** The 1-based HL7 field position within the segment (`3` is the third field). */ + readonly position: number; + /** The v2 location the issue reports (`"PID.3"`), never a value. */ + readonly location: string; + /** The FHIR path this map sources the field from (`"Patient.identifier"`). */ + readonly fhirPath: string; +} + +/** + * A reverse shape's fixed identity: the message code it emits under, the segment it carries, the + * resource it converts, and that segment's required fields. All four are library-owned constants; + * none is derived from input content, so none can carry a value into a diagnostic. + * + * @example + * ```ts + * // { messageCode: "ADT", segment: "PID", resourceName: "Patient", required: PID_REQUIRED } + * ``` + */ +export interface ReverseShape { + /** The fixed MSH-9.1 message code (`"ADT"`, `"ORU"`). */ + readonly messageCode: string; + /** The segment this shape appends (`"PID"`, `"OBX"`). */ + readonly segment: string; + /** The resource type this shape converts, for the FHIR path of a whole-message diagnostic. */ + readonly resourceName: string; + /** The segment's v2-required fields, in field order. */ + readonly required: readonly RequiredV2Field[]; +} + /** * Build the complete message for a shape: `buildMessage` with the shape's fixed message code and the - * caller's trigger, then the mapped segment appended to it. Returns `undefined` when the segment - * would carry no field at all, so an empty segment is never emitted. + * caller's trigger, then the mapped segment appended to it, and finally the honest account of what + * the resource could not supply. + * + * Two outcomes, and each is declared: * - * @param messageCode - The shape's fixed MSH-9.1 message code (`"ADT"`, `"ORU"`). + * - **Nothing grounded a field.** No message is built (an empty segment is never emitted) and + * {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED} is raised, so `{ value: undefined }` can + * never be read as a successful empty conversion. The required-field rows are *not* also raised + * here: there is no emitted segment for a field to be absent from, and restating it per field + * would add noise, not information. + * - **A message is built.** Every one of the shape's required fields that no mapped content reached + * raises {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}. The wire is untouched by this: + * the issue reports the field's absence, it never fills it. + * + * @param shape - The shape's fixed identity and its segment's required fields. * @param trigger - The caller's bare trigger, used verbatim as MSH-9.2. - * @param segment - The segment name to append (`"PID"`, `"OBX"`). * @param byPosition - The mapped fields, keyed by 1-based HL7 field position. * @param ctx - The resolved reverse context (its `envelope` supplies the MSH fields). + * @param issues - The issue sink. * @example * ```ts - * // emitMessage("ADT", "A28", "PID", fields, ctx)?.toString() + * // emitMessage(PATIENT_SHAPE, "A28", fields, ctx, issues)?.toString() * // -> "MSH|^~\\&|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r" * ``` */ export function emitMessage( - messageCode: string, + shape: ReverseShape, trigger: string, - segment: string, byPosition: ReadonlyMap, ctx: ReverseContext, + issues: TransformIssue[], ): Hl7Message | undefined { - if (byPosition.size === 0) return undefined; - return buildMessage({ ...ctx.envelope, type: `${messageCode}^${trigger}` }).addSegment( - segment, + if (byPosition.size === 0) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, shape.segment, shape.resourceName), + ); + return undefined; + } + for (const field of shape.required) { + if (byPosition.has(field.position)) continue; + issues.push( + issue(ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, field.location, field.fhirPath), + ); + } + return buildMessage({ ...ctx.envelope, type: `${shape.messageCode}^${trigger}` }).addSegment( + shape.segment, segmentFields(byPosition), ); } diff --git a/src/reverse/observation.ts b/src/reverse/observation.ts index ecc4400..1d5f86e 100644 --- a/src/reverse/observation.ts +++ b/src/reverse/observation.ts @@ -47,7 +47,9 @@ import { flagUnmapped, hasTrigger, readResource, + type RequiredV2Field, type ReverseResult, + type ReverseShape, } from "./message.js"; import { at, readComplexes, readNumberText, readString } from "./read.js"; import { invertCodeMap, v2Field, v2Number, v2Timestamp, type V2Components } from "./v2.js"; @@ -84,6 +86,31 @@ const OBSERVATION_MAPPED: ReadonlySet = new Set([ "valueDateTime", ]); +// The OBX rows whose v2.5.1 usage is `R`, and ONLY those two. OBX-2 (Value Type) +// and OBX-5 (Observation Value) are `C`, conditional on each other rather than +// required, and OBX-4 is `C` too, so none of the three is declared here: a +// conditional field reported as required is a false diagnostic on a clinical +// field. OBX-1 and OBX-6 through OBX-14 are `O`. Read the grounding banner above +// `RequiredV2Field` in `message.ts` before adding a row to this list. +/** The OBX fields v2.5.1 requires, with the `Observation` element this map sources each from. */ +const OBX_REQUIRED: readonly RequiredV2Field[] = [ + // OBX-3 Observation Identifier. UNREACHABLE TODAY BY CONSTRUCTION, and kept + // anyway: `obxFields` returns no field at all when it cannot build OBX-3, so an + // emitted OBX always carries one and this row can only fire if that early return + // is ever relaxed. It guards the next edit rather than changing this one. + { position: 3, location: "OBX.3", fhirPath: "Observation.code" }, + // OBX-11 Observation Result Status. + { position: 11, location: "OBX.11", fhirPath: "Observation.status" }, +]; + +/** What `toV2Observation` emits: an `ORU` message carrying an `OBX`, required fields above. */ +const OBSERVATION_SHAPE: ReverseShape = { + messageCode: "ORU", + segment: "OBX", + resourceName: "Observation", + required: OBX_REQUIRED, +}; + /** `Observation` elements with a known OBX/OBR home this narrow map does not implement. */ const OBSERVATION_UNMAPPED: Readonly> = Object.freeze({ valueBoolean: "OBX.5", @@ -389,6 +416,12 @@ function obxFields( * patient, and a subject segment assembled from a reference would be fabricated. Lossy by design and * never round-trip-safe. * + * An `OBX` field v2 requires (OBX-11 Observation Result Status) that this resource gives no source + * for stays absent and is declared with {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}, + * rather than defaulted to `F`: a result reported as final that the sender never called final is the + * confidently wrong value this library exists to refuse. An `Observation` that grounds no `OBX` + * field at all yields no message and {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED}. + * * @param resource - The FHIR `Observation` node. * @param trigger - The bare v2 trigger, e.g. `"R01"`. Required; never derived from the resource. * @param options - Caller-vetted reverse context: code systems and the MSH envelope. @@ -418,6 +451,7 @@ export function toV2Observation( flagUnmapped(observation, OBSERVATION_MAPPED, OBSERVATION_UNMAPPED, "Observation", "OBX", issues); const ctx = reverseContext(options); - const value = emitMessage("ORU", trigger, "OBX", obxFields(observation, ctx, issues), ctx); + const fields = obxFields(observation, ctx, issues); + const value = emitMessage(OBSERVATION_SHAPE, trigger, fields, ctx, issues); return { value, issues }; } diff --git a/src/reverse/patient.ts b/src/reverse/patient.ts index 6a661fd..97e289e 100644 --- a/src/reverse/patient.ts +++ b/src/reverse/patient.ts @@ -41,7 +41,9 @@ import { flagUnmapped, hasTrigger, readResource, + type RequiredV2Field, type ReverseResult, + type ReverseShape, } from "./message.js"; import { at, readComplexes, readString, readStrings } from "./read.js"; import { invertCodeMap, v2Date, v2Field, type V2Components } from "./v2.js"; @@ -94,6 +96,28 @@ const PATIENT_MAPPED: ReadonlySet = new Set([ "address", ]); +// The PID rows whose v2.5.1 usage is `R`, and ONLY those two. PID-1 is `O`, PID-2 +// and PID-4 are `B` (retained for backward compatibility, not required), and +// PID-6, PID-7, PID-8 and PID-11 are `O`, so none of them is declared here even +// though this map writes three of them: declaring an optional field as required +// would be a false diagnostic on a clinical field. Read the grounding banner above +// `RequiredV2Field` in `message.ts` before adding a row to this list. +/** The PID fields v2.5.1 requires, with the `Patient` element this map sources each from. */ +const PID_REQUIRED: readonly RequiredV2Field[] = [ + // PID-3 Patient Identifier List, v2.5.1 item 00106. + { position: 3, location: "PID.3", fhirPath: "Patient.identifier" }, + // PID-5 Patient Name, v2.5.1 item 00108. + { position: 5, location: "PID.5", fhirPath: "Patient.name" }, +]; + +/** What `toV2Patient` emits: an `ADT` message carrying a `PID`, whose required fields are above. */ +const PATIENT_SHAPE: ReverseShape = { + messageCode: "ADT", + segment: "PID", + resourceName: "Patient", + required: PID_REQUIRED, +}; + /** `Patient` elements with a known PID home this narrow map does not implement. */ const PATIENT_UNMAPPED: Readonly> = Object.freeze({ telecom: "PID.13", @@ -288,6 +312,12 @@ function pidFields( * Lossy by design and never round-trip-safe: a value the inverse of the IG map cannot ground is * flagged and left absent, never guessed, and a resource of another type is refused outright. * + * A `PID` field v2 requires (PID-3 Patient Identifier List, PID-5 Patient Name) that this resource + * gives no source for stays absent and is declared with + * {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}: the emitted message is honest about what + * it lacks rather than padded to look conformant. A `Patient` that grounds no `PID` field at all + * yields no message and {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED}. + * * @param resource - The FHIR `Patient` node (build one with `@cosyte/fhir`'s `parseResource`). * @param trigger - The bare v2 trigger, e.g. `"A28"`. Required; never derived from the resource. * @param options - Caller-vetted reverse context: assigning authorities, code systems, MSH envelope. @@ -315,6 +345,7 @@ export function toV2Patient( flagUnmapped(patient, PATIENT_MAPPED, PATIENT_UNMAPPED, "Patient", "PID", issues); const ctx = reverseContext(options); - const value = emitMessage("ADT", trigger, "PID", pidFields(patient, ctx, issues), ctx); + const fields = pidFields(patient, ctx, issues); + const value = emitMessage(PATIENT_SHAPE, trigger, fields, ctx, issues); return { value, issues }; } diff --git a/test/diagnostics/codes-and-issue.test.ts b/test/diagnostics/codes-and-issue.test.ts index 7d223c3..49827b3 100644 --- a/test/diagnostics/codes-and-issue.test.ts +++ b/test/diagnostics/codes-and-issue.test.ts @@ -37,9 +37,11 @@ describe("stable code registries", () => { "TRANSFORM_CODE_NOT_INVERTIBLE", "TRANSFORM_CODE_SYSTEM_NOT_V2", "TRANSFORM_MISSING_TRIGGER", + "TRANSFORM_NO_V2_MESSAGE_EMITTED", "TRANSFORM_NO_V2_TARGET", "TRANSFORM_RESOURCE_MALFORMED", "TRANSFORM_UNSUPPORTED_RESOURCE", + "TRANSFORM_V2_REQUIRED_FIELD_ABSENT", "TRANSFORM_VALUE_NOT_REPRESENTABLE", ].sort(), ); diff --git a/test/reverse/observation.test.ts b/test/reverse/observation.test.ts index 86d6b98..9e41be8 100644 --- a/test/reverse/observation.test.ts +++ b/test/reverse/observation.test.ts @@ -97,6 +97,61 @@ describe("toV2Observation: the emitted message", () => { }); }); +describe("toV2Observation: a v2-required field with no FHIR source is declared, never fabricated", () => { + // The one fixture the wire pin needs to be exact about: no display text, so OBX-3 reads `789-8^^LN`. + const bare = () => + parseResource( + '{"resourceType":"Observation","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]}}', + ).resource; + + it("flags OBX-11 when the Observation carries no status", () => { + const result = toV2Observation(bare(), "R01"); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(result.issues[0]?.v2Location).toBe("OBX.11"); + expect(result.issues[0]?.fhirPath).toBe("Observation.status"); + // Absent, not defaulted: a result the sender never called final is never reported as final. + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBeUndefined(); + }); + + it("flags OBX-11 when a status arrives but its inverse is not usable", () => { + const result = toV2Observation(observation({ status: "entered-in-error" }), "R01"); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ]); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBeUndefined(); + }); + + it("raises nothing when the required status is sourced", () => { + expect(toV2Observation(observation({ status: "final" }), "R01").issues).toEqual([]); + }); + + it("declares the absence without touching a single byte of the wire", () => { + // Byte-for-byte the wire this shape emitted before the absence was declared. + const result = toV2Observation(bare(), "R01", { + envelope: { controlId: "MSGID1", timestamp: "20260102101500" }, + }); + expect(result.value?.toString()).toBe( + "MSH|^~\\&|||||20260102101500||ORU^R01|MSGID1|P|2.5\rOBX|||789-8^^LN\r", + ); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + }); + + it("says so when it declines to emit any message at all", () => { + const result = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, + ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, + ]); + expect(result.issues[1]?.v2Location).toBe("OBX"); + expect(result.issues[1]?.fhirPath).toBe("Observation"); + }); +}); + describe("toV2Observation: the required trigger", () => { it("refuses an empty trigger without building a message", () => { const result = toV2Observation(observation({ status: "final" }), ""); diff --git a/test/reverse/patient.test.ts b/test/reverse/patient.test.ts index cb59a69..388d3ab 100644 --- a/test/reverse/patient.test.ts +++ b/test/reverse/patient.test.ts @@ -83,10 +83,13 @@ describe("toV2Patient: the emitted message", () => { expect(parseHL7(wire).get("PID.5.1")).toBe("Do^e|Public"); }); - it("emits no message when nothing in the resource maps to a PID field", () => { + it("emits no message when nothing in the resource maps to a PID field, and says so", () => { const result = toV2Patient(patient({ active: true }), "A28"); expect(result.value).toBeUndefined(); - expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, + ]); }); it("seeds the assigning authority only from the caller, never from the system URI", () => { @@ -96,14 +99,81 @@ describe("toV2Patient: the emitted message", () => { { assigningAuthorities: { "urn:oid:1.2.3": "HOSP" } }, ); expect(parseHL7(withSeed.value?.toString() ?? "").get("PID.3.4")).toBe("HOSP"); - expect(withSeed.issues).toEqual([]); + // The seeding itself raises nothing. What this fixture DOES raise is PID-5: it carries no name, + // and PID-5 is v2-required, so its absence is declared rather than left for the receiver. + expect(codes(withSeed)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(withSeed.issues[0]?.v2Location).toBe("PID.5"); const unseeded = toV2Patient( patient({ identifier: [{ value: "MRN1", system: "urn:oid:1.2.3" }] }), "A28", ); expect(parseHL7(unseeded.value?.toString() ?? "").get("PID.3.4")).toBe(undefined); - expect(codes(unseeded)).toEqual([ISSUE_CODES.TRANSFORM_NO_V2_TARGET]); + expect(codes(unseeded)).toEqual([ + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ]); + }); +}); + +describe("toV2Patient: a v2-required field with no FHIR source is declared, never fabricated", () => { + it("flags PID-3 and PID-5 when the Patient carries neither an identifier nor a name", () => { + const result = toV2Patient(patient({ gender: "female" }), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ); + expect(flagged.map((i) => i.v2Location)).toEqual(["PID.3", "PID.5"]); + expect(flagged.map((i) => i.fhirPath)).toEqual(["Patient.identifier", "Patient.name"]); + + // The other half of the same rule, asserted in the same case: absent, not fabricated. Neither + // field carries a placeholder, an empty component, or anything else invented to satisfy v2. + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("PID.3")).toBeUndefined(); + expect(round.get("PID.5")).toBeUndefined(); + }); + + it.each([ + ["PID.3", "Patient.identifier", { name: [{ family: "Public" }] }], + ["PID.5", "Patient.name", { identifier: [{ value: "MRN1" }] }], + ])( + "flags %s alone when the resource sources the other required field", + (location, path, json) => { + const result = toV2Patient(patient(json), "A28"); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(result.issues[0]?.v2Location).toBe(location); + expect(result.issues[0]?.fhirPath).toBe(path); + }, + ); + + it("raises nothing when both required fields are sourced", () => { + const result = toV2Patient( + patient({ identifier: [{ value: "MRN1" }], name: [{ family: "Public" }] }), + "A28", + ); + expect(result.issues).toEqual([]); + }); + + it("declares the absence without touching a single byte of the wire", () => { + // The exact wire this shape emitted before the absence was declared, pinned byte for byte: this + // is a diagnostics-only contract, so no placeholder, no empty component, no reordering and no + // extra field may appear because a required field went unsourced. + const result = toV2Patient(patient({ identifier: [{ value: "MRN1" }] }), "A28", { + envelope: { controlId: "MSGID1", timestamp: "20260102101500" }, + }); + expect(result.value?.toString()).toBe( + "MSH|^~\\&|||||20260102101500||ADT^A28|MSGID1|P|2.5\rPID|||MRN1\r", + ); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + }); + + it("says so when it declines to emit any message at all", () => { + // Nothing grounded a PID field, so there is no segment for a field to be absent from: one code + // declares the whole outcome, and `{ value: undefined }` is never a silent empty success. + const result = toV2Patient(patient({}), "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED]); + expect(result.issues[0]?.v2Location).toBe("PID"); + expect(result.issues[0]?.fhirPath).toBe("Patient"); }); });