@cosyte/astm 0.0.21 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,77 +7,229 @@
7
7
 
8
8
  # @cosyte/astm
9
9
 
10
- > ASTM parser, serializer, and builder for Node.js and TypeScript: **lenient on parse,
11
- > spec-clean on emit**.
10
+ > Read a real analyzer's ASTM traffic in one line, and never get a confident wrong value back.
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@cosyte/astm.svg)](https://www.npmjs.com/package/@cosyte/astm)
13
+ [![CI](https://github.com/cosyte/astm/actions/workflows/ci.yml/badge.svg)](https://github.com/cosyte/astm/actions/workflows/ci.yml)
14
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cosyte/astm/blob/main/LICENSE)
15
+ [![Node: >=22.0.0](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org)
16
+
17
+ ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
18
+
19
+ - [Why this exists](#why-this-exists)
20
+ - [Status](#status)
21
+ - [Install](#install)
22
+ - [Usage](#usage)
23
+ - [PHI and safety](#phi-and-safety)
24
+ - [API](#api)
25
+ - [Compatibility](#compatibility)
26
+ - [Contributing](#contributing)
27
+ - [License](#license)
28
+
29
+ ## Why this exists
30
+
31
+ Lab integration teams receive ASTM off analyzers that each read the standard a little differently, and the usual answer is a hand-rolled split on `|` and `^` that hands back a confidently wrong value the first time a vendor escapes a delimiter or redeclares the set mid-stream. `@cosyte/astm` removes that: it reads the delimiters each header declares, decodes escape sequences before it splits a value, keeps the practice- and laboratory-assigned patient IDs distinct, and reports every deviation it tolerated as a stable, value-free warning. The nearest alternatives are the open-source ASTM codecs, `python-astm` and `senaite.astm`, which are Python and which hardcode the canonical `|\^&` delimiter set. This is a typed, zero-dependency Node.js package built on the rule those do not enforce: a deviation it cannot resolve is surfaced to you rather than resolved on your behalf, so it does not hand back a value the bytes do not carry.
32
+
33
+ ## Status
34
+
35
+ `0.1.0`. The public API is settled and is stable enough to depend on. The record, framing, transport, emit, profile and terminology layers are all shipped, and their exported names, options and return shapes are what a consumer builds against. Warning and error codes are part of that surface: consumers branch on `w.code`, so renaming one is a breaking change and is treated as one.
36
+
37
+ Two public surfaces are still moving, named here rather than left to be found:
38
+
39
+ - **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
40
+ - **The warning and error code sets.** A code is added when a deviation is measured that nothing reported before, and a new code is safety-critical by default, so a profile that tolerated the old reports does not tolerate the new one. Adding a code is additive, though it can change which streams a `{ strict: true }` parse refuses; renaming one is breaking and is treated as breaking.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ # Requires Node.js >=22.0.0. Ships dual ESM + CJS, with zero runtime dependencies.
46
+ pnpm add @cosyte/astm
47
+ # or
48
+ npm install @cosyte/astm
49
+ ```
50
+
51
+ - **Node.js `>=22.0.0`.** That floor is `package.json` `engines.node`; nothing below it is supported.
52
+ - **Dual ESM and CJS.** `import` resolves to `dist/index.mjs` and `require` to `dist/index.cjs`, each with its own type declarations, so the package loads from either module system without an interop shim.
53
+ - **Zero runtime dependencies.** `dependencies` is empty and the package imports no Node built-in.
54
+
55
+ ## Usage
56
+
57
+ Hand a de-framed record stream to `parseAstmRecords` and read the result in one line. Every value below is synthetic.
58
+
59
+ ```ts
60
+ import { parseAstmRecords, patient, results } from "@cosyte/astm";
61
+
62
+ // Synthetic ASTM records, CR-delimited. The header declares the delimiters.
63
+ const stream =
64
+ "H|\\^&|||analyzer^1|||||||P|LIS02-A2|20240115103000\r" +
65
+ "P|1|PRACTICE-0001|LAB-0002|||||F\r" +
66
+ "O|1|SPEC-7|ACC-42|^^^687|||20240115102500\r" +
67
+ "R|1|^^^687|28.6|U/L|10-40|N||F\r" +
68
+ "L|1|N\r";
69
+
70
+ const msg = parseAstmRecords(stream);
71
+ const [first] = results(msg);
72
+
73
+ console.log(first?.value, first?.units);
74
+ console.log(first?.status.meaning, first?.status.isActiveFinal);
75
+ console.log(first?.flag?.meaning);
76
+ console.log(first?.universalTestId?.localCode);
77
+ console.log(patient(msg)?.practiceAssignedId, patient(msg)?.laboratoryAssignedId);
78
+ console.log(msg.warnings.length);
79
+ ```
80
+
81
+ ```text
82
+ 28.6 U/L
83
+ final true
84
+ normal
85
+ 687
86
+ PRACTICE-0001 LAB-0002
87
+ 0
88
+ ```
89
+
90
+ Six things are worth reading off that output. The value and the units are surfaced raw, never converted and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing, which is what the rest of this file is about.
91
+
92
+ That block is executed on every test run and its output is asserted against the text beside it (`test/readme-usage.test.ts`), so an example that drifts from the package fails the build rather than misleading a reader.
93
+
94
+ ## PHI and safety
95
+
96
+ ASTM instrument traffic carries PHI. This section says what the library does with it and, where a guarantee cannot be grounded in the code, states the limit instead.
97
+
98
+ **What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
99
+
100
+ **What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
101
+
102
+ **What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks or de-identifies. It surfaces what arrived.
103
+
104
+ **One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone and comment free text is not implemented here. It guards this repository's own fixtures, tests and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
105
+
106
+ ## API
12
107
 
13
108
  `@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
14
109
  parser that turns real-world, vendor-quirky input into **warnings** rather than failures, paired with
15
110
  a serializer that always emits spec-clean output (Postel's Law). It mirrors the API shape of the
16
111
  reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
17
112
 
18
- > **Status:** published on npm, on the pre-alpha `0.0.x` ladder. Both layers are
19
- > feature-complete, but the public surface can still change within `0.0.x`, before `0.1.0`.
113
+ Every export carries JSDoc that compiles into `dist/index.d.ts`, so the full signature reference is what your editor already shows on hover; the source is at [github.com/cosyte/astm/tree/main/src](https://github.com/cosyte/astm/tree/main/src). What follows is the shape of each layer and the caveats that bite.
20
114
 
21
- ## What it covers
115
+ ### Parse records
22
116
 
23
- - **Records (E1394 / LIS02-A2).** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read with per-header
24
- delimiter self-declaration and escape decode, so an escaped delimiter inside a value reads as one
25
- component. Result semantics are modeled and fail-safe: HL7 Table 0078 abnormal flags, result status
26
- (a correction `C` or cancel `X` never reads as active-final), reference ranges kept verbatim, and a
27
- missing unit flagged rather than defaulted. The practice-, laboratory-, and third patient IDs stay
28
- distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
29
- a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
30
- as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
31
- surfaced verbatim rather than interpreted into clinical fields.
32
- - **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
33
- frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
34
- untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
35
- reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
36
- cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
37
- socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
38
- for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
39
- - **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
40
- delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
41
- frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
42
- construction, and a delimiter set that fails any of the three conditions readback requires (one
43
- character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
44
- and lost. Those three read the set alone, so each record is additionally checked against the set it
45
- is written with: a separator equal to a record's own type letter escapes that letter away and the
46
- record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
47
- output. That second check is about **transcoding**, not about which set you asked for, so it can
48
- fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
49
- whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
50
- reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
51
- byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
52
- character encoding for you, and it never quietly writes a different character than the one you gave
53
- it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
54
- record is a typed error as well, in either form: those three are what the decoder reads as the shape
55
- of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
56
- that byte, silently losing a whole record whenever the two bytes after it happened to be the short
57
- frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
58
- wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
59
- compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
60
- record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
61
- `7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
62
- the **default** start; a non-default one writes a continuation of a sequence already in progress,
63
- and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
64
- about and not emitted.
65
- - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
66
- downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
67
- ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
68
- status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
69
- unrecognized record type, and any frame or transport integrity warning. The gate runs when a
70
- profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
71
- plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
72
- make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
73
- message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
74
- quirk document.
75
- - **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
76
- LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
77
- mutates the raw code or value and never guesses a LOINC. No LOINC, SNOMED, or LIVD dictionary is
78
- bundled: the package stays a structural recognizer and you bring the catalog.
117
+ ```ts
118
+ import { parseAstmRecords, results, patient } from "@cosyte/astm";
79
119
 
80
- ## Decode a framed byte stream
120
+ // A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
121
+ const msg = parseAstmRecords(raw);
122
+
123
+ results(msg)[0]?.value; // the measured value, surfaced raw
124
+ results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
125
+ patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
126
+ msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
127
+ ```
128
+
129
+ The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
130
+ produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
131
+ record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
132
+ `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
133
+
134
+ ### Several messages in one stream
135
+
136
+ A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
137
+ splits a parsed stream into them, and each entry carries only its own records, so a patient is only
138
+ ever paired with the results that message actually carried:
139
+
140
+ ```ts
141
+ import { parseAstmRecords, messages } from "@cosyte/astm";
142
+
143
+ for (const m of messages(parseAstmRecords(raw))) {
144
+ m.patient?.practiceAssignedId; // the P for THIS message
145
+ m.results; // the Rs for THIS message
146
+ m.delimiters; // the set THIS message's records were read with
147
+ }
148
+ ```
149
+
150
+ The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
151
+ so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
152
+ across patients:
153
+
154
+ - `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
155
+ - `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
156
+ one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
157
+
158
+ **Both are breaking**, and the second one reaches single-message callers: a lone message carrying
159
+ several patients used to answer with the first of them. A stream that is one message with at most one
160
+ patient is unchanged, and so is a result-only message with no `P` at all, which still answers
161
+ `undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
162
+ already names the message.
163
+
164
+ Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
165
+ you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
166
+ byte for instance, opens no message, and the messages either side of it merge back into one, so a
167
+ patient can end up holding results that arrived under a different header. The
168
+ parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
169
+ only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
170
+ a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
171
+ not gate on the warning count, though, because the records that merged in can raise warnings of
172
+ their own.
173
+
174
+ Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
175
+ records after it are read with the previous set and their fields can be lost rather than merely
176
+ misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
177
+ the delimiters in force found no field separator in it at all, so the whole line read back as one
178
+ field and none of its modeled fields survived. On a result record that is the value, the units and
179
+ the status at once, so treat it as a lost result, not a formatting nit. The fields are never
180
+ reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
181
+ code is safety-critical, and it does not need a mangled header to fire: a lone record written in
182
+ another set trips it too.
183
+
184
+ **Its absence does not certify that a record was read in its own set**, and this is the important
185
+ half. The check tests one of the four delimiter roles, the **field** separator, and only in its
186
+ total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
187
+ outside it:
188
+
189
+ - A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
190
+ the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
191
+ the value, the units and the status with no warning at all, while the identical record without
192
+ that one byte is reported. This also happens **inside** a run of these warnings, so even a run
193
+ does not mean every record in it was checked.
194
+ - A set differing in the **repeat, component or escape** role usually splits into fields normally,
195
+ and the damage then varies. A mis-split component can cost a test identity while the value and
196
+ units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
197
+ longer merges the rest of the record (it reads as a literal and raises
198
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
199
+ that is itself a delimiter in force is an opaque
200
+ atom, so that delimiter does not split and the value, the units and the status can still go
201
+ together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
202
+ alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
203
+ where the leftmost alignment lets a delimiter split that a competing alignment would have held,
204
+ gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
205
+ not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
206
+ an escape character heading no sequence it can interpret, every later field shifts and a
207
+ result's units
208
+ and status are read out of slots the other alignment does not put them in: that raises
209
+ `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
210
+ nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
211
+ truncates a value and costs a test identity or a patient name the components that sat after it:
212
+ that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
213
+ **component** boundary nothing leaves the record and every component after it moves along the
214
+ component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
215
+ alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
216
+ tolerable either.
217
+
218
+ All are accepted limits, for two different reasons: widening the field-separator check would mean
219
+ deciding which set a record ought to have had, which is the same guess the parser declines to make
220
+ elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
221
+ written down rather than papered over. Read the warning as "this record definitely lost its
222
+ fields", never as "no other record did". If
223
+ delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
224
+ outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
225
+ invalidating what follows it rather than expecting this warning to enumerate the damage.
226
+
227
+ An unrecognized type letter also makes the message **kind** unknowable, because the letter that
228
+ could not be read may have been the very `Q` that decides it. `classification.kind` is
229
+ `indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
230
+ says why. A `Q` that was read still wins outright.
231
+
232
+ ### Decode a framed byte stream
81
233
 
82
234
  ```ts
83
235
  import { decodeAstmFrames, parseFramedAstm, results } from "@cosyte/astm";
@@ -96,7 +248,7 @@ A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>24
96
248
  **warning** in the default lenient mode (surfaced, flagged, never silently trusted) and a thrown
97
249
  `AstmFrameStrictError` under `{ strict: true }`.
98
250
 
99
- ## Drive the transport (framed vs raw) + the LTP protocol
251
+ ### Drive the transport (framed vs raw) + the LTP protocol
100
252
 
101
253
  ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
102
254
  vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
@@ -140,30 +292,339 @@ fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retr
140
292
  The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
141
293
  state transitions, not the wall-clock timers.
142
294
 
143
- ## Install
295
+ ### Map local codes to LOINC (LIVD, bring-your-own)
144
296
 
145
- ```bash
146
- npm install @cosyte/astm
297
+ An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
298
+ downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
299
+ mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
300
+ ambiguous code is surfaced as such, **never a guessed LOINC**.
301
+
302
+ ```ts
303
+ import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
304
+
305
+ // Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
306
+ const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
307
+
308
+ const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
309
+ const { annotations, warnings } = applyLivd(msg, catalog);
310
+
311
+ annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
312
+ warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
147
313
  ```
148
314
 
149
- ## Parse
315
+ **Your catalog answers the analyte-identity question, and the wire never does.** The Universal Test
316
+ ID's first component is a LOINC slot, and the guide this catalog format comes from puts transmitting
317
+ LOINC directly from IVD instruments explicitly out of scope: the analyte arrives as a vendor-defined
318
+ code. So the lookup happens whenever a vendor local code is present, keyed on that code alone, and a
319
+ populated first component neither answers for it nor selects among candidates. **This package
320
+ performs no LOINC validation of any kind**: it never decides whether such a value "looks like" a
321
+ LOINC, so `Glucose` and `2345-7` there are treated identically. The value is carried verbatim as
322
+ `unvalidatedWireValue`, on every disposition, and is never reported as a LOINC.
150
323
 
151
324
  ```ts
152
- import { parseAstmRecords, results, patient } from "@cosyte/astm";
325
+ const msg = parseAstmRecords("H|\\^&\rR|1|Glucose^^^687|28.6|U/L||N||F\rL|1\r");
326
+ const [a] = applyLivd(msg, catalog).annotations;
153
327
 
154
- // A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
155
- const msg = parseAstmRecords(raw);
328
+ a?.mapping.status; // "mapped": your catalog vouched for 1920-8 (the lookup used "687")
329
+ a?.reportedCode; // "687": the code the catalog was consulted WITH, verbatim
330
+ a?.unvalidatedWireValue; // "Glucose": carried verbatim, vouched for by nothing
331
+ a?.wireValueDisagreesWithCatalog; // true: the two differ, and that is ALL this says
332
+ ```
156
333
 
157
- results(msg)[0]?.value; // the measured value, surfaced raw
158
- results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
159
- patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
160
- msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
334
+ `mapping.status` is a closed discriminant, so a `switch` over it is exhaustive:
335
+
336
+ | `status` | what happened | warning |
337
+ | ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
338
+ | `mapped` | your catalog vouched for exactly one LOINC for the vendor local code | none |
339
+ | `unmapped` | the vendor local code was looked up and your catalog held no entry | `ASTM_LIVD_UNMAPPED_CODE` |
340
+ | `ambiguous` | several distinct LOINCs the reported units did not settle; all surfaced, **none chosen** | `ASTM_LIVD_AMBIGUOUS_MAPPING` |
341
+ | `no-vendor-code` | component 1 is populated and there is no vendor local code: nothing was looked up | none |
342
+ | `no-code` | the Universal Test ID carried no code at all | none |
343
+
344
+ `unvalidatedWireValue` and `wireValueDisagreesWithCatalog` sit **beside** that discriminant and can
345
+ accompany any of its cases. `wireValueDisagreesWithCatalog` is `true` only where your catalog
346
+ vouched for exactly one LOINC, component 1 is populated, and the two are not byte-identical; it is
347
+ `false` everywhere else and is never absent, so an ordinary `R|1|^^^687|...` record ships no
348
+ standing false disagreement. **It reports the difference and nothing else**: both values stay
349
+ surfaced, neither is marked correct, neither is rewritten, and nothing here says the difference was
350
+ settled. Deciding which source to believe is a clinical judgement this library will not make for
351
+ you.
352
+
353
+ Two corners worth knowing before you write a catalog adapter: a `lookup` that **throws** propagates
354
+ to your caller unchanged rather than reading as a catalog miss (your crash must not be
355
+ indistinguishable from "this code is not in the catalog"), and a hit whose `loinc` is a zero-length
356
+ string is reported as a miss rather than as a vouched-for empty LOINC.
357
+
358
+ #### One vendor code, several LOINCs: the units decide
359
+
360
+ One vendor analyte code mapping to several LOINCs is the **ordinary** case, not an edge: the guide
361
+ this catalog format comes from gives the commonest chemistry analytes as its own worked examples, a
362
+ serum glucose reported as a mass concentration versus a substance concentration, and a urine analyte
363
+ reported as a spot concentration versus a 24 hour excretion rate. Its remedy is a mapping per unit,
364
+ so a catalog row can carry a **representative unit of measure** beside the vendor code, and the
365
+ `R` record already states its units on the wire.
366
+
367
+ ```ts
368
+ import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
369
+
370
+ const glucose = defineLivdCatalog([
371
+ { vendorCode: "GLU", loinc: "2345-7", representativeUnit: "mg/dL" },
372
+ { vendorCode: "GLU", loinc: "14749-6", representativeUnit: "mmol/L" },
373
+ ]);
374
+
375
+ const msg = parseAstmRecords("H|\\^&\rR|1|^^^GLU|5.5|mmol/L||N||F\rL|1\r");
376
+ applyLivd(msg, glucose).annotations[0]?.mapping;
377
+ // { status: "mapped", loinc: "14749-6", representativeUnit: "mmol/L",
378
+ // unitComparison: { comparison: "verbatim-case-sensitive", ucumSemantic: false, ... },
379
+ // source: "livd", derived: true }
161
380
  ```
162
381
 
163
- The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
164
- produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
165
- record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
166
- `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
382
+ **The comparison is verbatim and case sensitive, and it is not UCUM.** A catalog saying `mg/dL` does
383
+ not match a feed saying `MG/DL`, and it never matches `g/L` however convertible the two are: nothing
384
+ is normalized, case folded, scaled or converted on either side. UCUM defines a case-insensitive
385
+ variant of every terminal symbol and requires a program claiming full conformance to compare unit
386
+ expressions by their **semantics**, so this is a deliberately limited choice, the only comparison
387
+ that cannot invent an equivalence. Because you could otherwise read a matched unit as a conformance
388
+ claim this package does not make, every unit-selected answer carries `unitComparison` saying exactly
389
+ what was compared.
390
+
391
+ Refusing still beats guessing, so all four of these are `ambiguous` with **every candidate surfaced
392
+ and no LOINC chosen**: the units match no candidate, they match more than one, the record reported no
393
+ units (absent, empty, or whitespace only) and the candidates differ only by unit, or the units could
394
+ not be read at all because the record was truncated or malformed. An unreadable units field reads as
395
+ "no units reported": you still get a typed annotation, never an exception and never a dropped record.
396
+ The answer's `reason` says which case it was, and `candidateDetails` carries each candidate row's
397
+ LOINC and LIVD attributes so a human can choose what this package will not.
398
+
399
+ A candidate whose `representativeUnit` is **absent, empty or whitespace only** is not unit qualified:
400
+ it is never selected by a unit comparison, and it is still surfaced among the candidates. And a
401
+ vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree, with
402
+ no `unitComparison`, because no unit chose anything there.
403
+
404
+ On a `mapped` answer, `representativeUnit` is provenance about the **catalog row** rather than a
405
+ restatement of what the record reported: where a code carries a single candidate LOINC across several
406
+ rows that spell the unit differently, the answer takes the first row's attributes, so that field can
407
+ name a unit the record did not report. **Only `unitComparison` says the two were equal**, and it is
408
+ present only where a unit actually chose between candidates. Branch on that field, not on this one.
409
+
410
+ Two attributes ride along and are **never matched on**: `vendorSpecimenDescription` and
411
+ `vendorResultDescription`. Both are free text, and the guide states directly that this information is
412
+ not intended to be parsed by software that automates the mapping, so they are stored and surfaced
413
+ verbatim for a human to read. Only the representative unit ever selects. All three attributes are
414
+ **optional**: a catalog carrying none of them answers exactly as it did before they existed, for
415
+ `mapped`, `unmapped` and `ambiguous` alike.
416
+
417
+ **No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
418
+ its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
419
+ separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
420
+ its license obligations).
421
+
422
+ > **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
423
+ > to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
424
+ > instruments that reuse the same code for different analytes would both match: supply a catalog built
425
+ > for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
426
+ > surfaced as `ambiguous`, never resolved to a guess.)
427
+
428
+ ### Read a date without inventing a timezone
429
+
430
+ **An ASTM timestamp carries no timezone, so it is not an instant.** The value is local to the
431
+ instrument that wrote it, and nothing on the wire says which zone that was. `toDate` therefore
432
+ returns `undefined` until **you** say what the offset was: it never reads the host machine's zone
433
+ and never assumes UTC. Both guesses are silent, and both shift a collection date by a day in every
434
+ negative-offset zone.
435
+
436
+ `parseAstmDate` reads the digit run into an `AstmDate` that keeps whatever precision the instrument
437
+ sent (`"20240315"` is a **day**, not a fabricated midnight). Three conversions read that value, and
438
+ they carry the same names, the same shapes and the same timezone rule in every `@cosyte` parser:
439
+
440
+ | function | returns | on a value it cannot convert |
441
+ | ------------------------- | ------------------------ | --------------------------------------------- |
442
+ | `toObject(value)` | `DateParts \| undefined` | `undefined` |
443
+ | `toISO(value)` | `string \| undefined` | `undefined` |
444
+ | `toDate(value, options?)` | `Date \| undefined` | `undefined`, including when no zone was given |
445
+
446
+ ```ts
447
+ import { parseAstmDate, toObject, toISO, toDate } from "@cosyte/astm";
448
+
449
+ const collected = parseAstmDate("20240315"); // day precision, no time, no zone
450
+
451
+ toObject(collected); // { year: 2024, month: 3, day: 15 } only what was stated
452
+ toISO(collected); // "2024-03-15" no trailing Z, ever
453
+ toDate(collected); // undefined the zone is unstated
454
+
455
+ // You know the analyzer runs on US Eastern standard time, so you say so:
456
+ toDate(collected, { assumeOffsetMinutes: -300 }); // 2024-03-15T05:00:00.000Z
457
+ toDate(collected, { assumeOffsetMinutes: 0 }); // 2024-03-15T00:00:00.000Z, "read it as UTC"
458
+ ```
459
+
460
+ `assumeOffsetMinutes` is signed minutes east of UTC, and an explicit `0` is a real answer ("treat
461
+ this value as UTC"), not a default. Components below the stated precision fill to their lowest legal
462
+ value for the instant only (month and day to 1, the time to 0); the value itself is untouched, so
463
+ `toObject` and `toISO` keep reporting the precision that arrived. A four-digit year below 100 stays
464
+ that year: `"00500101"` is the year 50, never 1950.
465
+
466
+ An offset that names no usable zone is refused rather than applied, and **the answer is never an
467
+ `Invalid Date`**: not a `NaN` or infinite `assumeOffsetMinutes`, and not one so large that applying
468
+ it lands outside the range a JS `Date` represents. Both give you `undefined`, like every other case
469
+ this surface cannot answer.
470
+
471
+ ```ts
472
+ toDate(collected, { assumeOffsetMinutes: Number.NaN }); // undefined
473
+ toDate(collected, { assumeOffsetMinutes: 1e15 }); // undefined, past the representable range
474
+ ```
475
+
476
+ An `Invalid Date` would satisfy the `Date | undefined` return type and defeat its point: you cannot
477
+ tell one from a real instant without testing `getTime()` for `NaN`, and calling `toISOString()` on it
478
+ throws. Checking for `undefined` is the only test you need.
479
+
480
+ `toObject` returns a **frozen** object carrying only the components the value stated, so
481
+ `Object.keys()` is the precision. There is no `precision`, `raw` or `truncated` key on it (that is
482
+ the parse record, and it stays on `AstmDate`), no zero-filling, and no key holding `undefined`. The
483
+ month is spec-native **1 to 12**, and the keys are singular, so deleting `offsetMinutes` (which an
484
+ ASTM value never has) leaves an object `Temporal.PlainDateTime.from` and luxon's
485
+ `DateTime.fromObject` accept with no key rename and no value adjustment. Neither library is a
486
+ dependency here: the shape is the interoperability, not an import.
487
+
488
+ **A component that is not on the calendar is refused, not normalised.** The parser is lenient by
489
+ design and will read `"19881301"` into month 13 rather than drop the field, so the conversions are
490
+ where that has to be answered, and all three of them answer `undefined`:
491
+
492
+ ```ts
493
+ parseAstmDate("19881301")?.month; // 13: the parser reports what arrived
494
+ toObject(parseAstmDate("19881301")); // undefined
495
+ toISO(parseAstmDate("19881301")); // undefined
496
+ toDate(parseAstmDate("19881301"), { assumeOffsetMinutes: 0 }); // undefined
497
+ ```
498
+
499
+ A JS `Date` would have rolled that month 13 into January **1989** and handed back a date of birth a
500
+ year out, with nothing to say so. A month outside 1 to 12, a day past the end of its own month (29
501
+ February in a common year included), an hour past 23 and a minute or second past 59 are all refused
502
+ the same way. The refusal is of the whole value, never of the offending component alone: `undefined`
503
+ rather than a year-precision `{ year: 1988 }` the instrument never sent.
504
+
505
+ `astmDateToLocalISO` is unchanged and still exported. `toISO` returns the same string for every value
506
+ this parser produces **whose components are a real calendar date**, under the name every `@cosyte`
507
+ parser answers to. On a value whose components are not, the two differ on purpose and in one
508
+ direction: `astmDateToLocalISO` still renders the digits it was handed (`"1988-13-01"`), which is its
509
+ long-standing behaviour, and `toISO` gives you `undefined` instead of a string no ISO-8601 reader
510
+ accepts.
511
+
512
+ #### Using two `@cosyte` parsers in one file
513
+
514
+ The three names are deliberately identical across the packages, so importing two of them collides.
515
+ Alias them, or namespace-import:
516
+
517
+ ```ts
518
+ import { parseAstmDate, toISO as astmToISO, toDate as astmToDate } from "@cosyte/astm";
519
+ import { toISO as hl7ToISO } from "@cosyte/hl7";
520
+
521
+ // Or namespace the whole package:
522
+ import * as astm from "@cosyte/astm";
523
+
524
+ astmToISO(parseAstmDate("202403150930")); // "2024-03-15T09:30"
525
+ astm.toDate(parseAstmDate("20240315"), { assumeOffsetMinutes: -300 });
526
+ ```
527
+
528
+ An HL7 v2 `DTM` can state its own offset and an ASTM timestamp never can, so the two answer
529
+ differently on purpose: `hl7ToISO` may end in `Z` or `+HH:MM`, `astmToISO` never does. That
530
+ difference is the standards', not the API's.
531
+
532
+ ### The cosyte parser archetype
533
+
534
+ - **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
535
+ spec-clean), so quirks don't propagate downstream on round-trip.
536
+ - **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
537
+ Tier 3 fatal always.
538
+ - **Stable warning codes**: warnings carry stable string codes + positional context; consumers
539
+ branch on `w.code`, so renaming a code is a breaking change.
540
+ - **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
541
+ - **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
542
+ - **Immutability**: parsed models are immutable; mutation is via explicit methods.
543
+ - **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
544
+ through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
545
+ to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
546
+ default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
547
+ - **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
548
+ (`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. The
549
+ catalog answers for the analyte identity, never the wire, and no LOINC validation of any kind is
550
+ performed. No LOINC / SNOMED / LIVD data is bundled.
551
+
552
+ ## Compatibility
553
+
554
+ - **Record content: ASTM E1394 / CLSI LIS02-A2.** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read; an unrecognized type letter is surfaced, never dropped.
555
+ - **Framing and transport: ASTM E1381 / CLSI LIS01-A2.** Modulo-256 checksum, frame-number sequencing, the 240-byte multi-frame split, and a pure `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine.
556
+ - **Delimiters come from the stream, never from an assumption.** They are read at every `H` and scoped forward, and records already read keep the set they were read with.
557
+ - **Framed and raw TCP are both handled.** Serial always frames; over TCP it varies within one vendor, so `detectFraming` routes cobas 4800 and Iguana (framed) from cobas b121 (framing dropped).
558
+ - **No named per-vendor profile ships.** The engine, the registry and the safety gate are public; the built-in set is `default` plus the corpus-grounded `referenceCorpus`. Named profiles for cobas, Sysmex, ADVIA, Mindray and Snibe stay gated behind a public, vendor-attributed quirk document, and firsthand inspection of the public corpus found the record layer spec-clean for them.
559
+ - **Three behaviors are reasoned from this reader rather than cited to a clause.** LIS02-A2 sections 5.4 and 6.2 are withheld from CLSI's free sample and the paywalled editions were not read here, so the forward-scoping rule for redeclared delimiters, the Latin-1 wire encoding and the reserved-byte set are this package's reading, not a quotation.
560
+ - **The result-status letter set is bound by no citable published source**, and every interpreted status reports that in its `vocabulary` rather than implying an attribution it does not have. Abnormal flags are graded against HL7 v3 ObservationInterpretation and report that vocabulary too, recognized or not.
561
+ - **The wire is read as Latin-1**, one byte per character. A character above `U+00FF` handed to the frame encoder is a typed error rather than a silently truncated byte, and a raw `STX`, `ETB` or `ETX` byte in a record is refused for the same reason: framing has no escape sequence for them.
562
+ - **No LOINC, SNOMED or LIVD dictionary is bundled**, and no LOINC is ever guessed. You bring the catalog and its license obligations.
563
+
564
+ ### What it covers
565
+
566
+ - **Records (E1394 / LIS02-A2).** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read with per-header
567
+ delimiter self-declaration and escape decode, so an escaped delimiter inside a value reads as one
568
+ component. Result semantics are modeled and fail-safe: abnormal flags graded against the HL7 v3
569
+ ObservationInterpretation code system, result status (a correction `C` or cancel `X` never reads as
570
+ active-final), reference ranges kept verbatim, and a missing unit flagged rather than defaulted.
571
+ Every interpreted flag and status reports the vocabulary it was graded against, recognized or not,
572
+ so an unknown code is distinguishable from one the library has not caught up to; the status letter
573
+ set reports that **no citable published source** binds it. The practice-, laboratory-, and third patient IDs stay
574
+ distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
575
+ a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
576
+ as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
577
+ surfaced verbatim rather than interpreted into clinical fields.
578
+ - **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
579
+ frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
580
+ untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
581
+ reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
582
+ cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
583
+ socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
584
+ for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
585
+ - **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
586
+ delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
587
+ frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
588
+ construction, and a delimiter set that fails any of the three conditions readback requires (one
589
+ character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
590
+ and lost. Those three read the set alone, so each record is additionally checked against the set it
591
+ is written with: a separator equal to a record's own type letter escapes that letter away and the
592
+ record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
593
+ output. That second check is about **transcoding**, not about which set you asked for, so it can
594
+ fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
595
+ whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
596
+ reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
597
+ byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
598
+ character encoding for you, and it never quietly writes a different character than the one you gave
599
+ it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
600
+ record is a typed error as well, in either form: those three are what the decoder reads as the shape
601
+ of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
602
+ that byte, silently losing a whole record whenever the two bytes after it happened to be the short
603
+ frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
604
+ wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
605
+ compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
606
+ record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
607
+ `7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
608
+ the **default** start; a non-default one writes a continuation of a sequence already in progress,
609
+ and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
610
+ about and not emitted.
611
+ - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
612
+ downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
613
+ ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
614
+ status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
615
+ unrecognized record type, and any frame or transport integrity warning. The gate runs when a
616
+ profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
617
+ plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
618
+ make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
619
+ message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
620
+ quirk document.
621
+ - **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
622
+ LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
623
+ mutates the raw code or value and never guesses a LOINC. The catalog is consulted whenever a
624
+ vendor local code is present and is keyed on that code alone; a value in the Universal Test ID's
625
+ first component is carried verbatim as an **unvalidated wire value**, never validated, never
626
+ reported as a LOINC, and never used as a lookup key. No LOINC, SNOMED, or LIVD dictionary is
627
+ bundled: the package stays a structural recognizer and you bring the catalog.
167
628
 
168
629
  ### An unescaped ampersand does not cost you the rest of the record
169
630
 
@@ -268,9 +729,9 @@ alignment gives it. A field's modeled value and components are taken from its **
268
729
  alone**, so where the gained boundary is the **first** one everything past it stays on the wire,
269
730
  stays in `repeats`, and leaves every modeled slot. On the canonical set, `R|1|^^^687|28.6&S&\&U/L|U/L||||F` reads a value of `28.6^`
270
731
  and drops `&U/L`, and `R|1|&F&\&687|28.6|U/L||||F` reads a Universal Test ID of one component
271
- holding a decoded field separator, so the local code `687` is in no modeled slot and the identity
272
- comes back as a LOINC candidate nobody wrote. A patient name loses its given and middle names the
273
- same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
732
+ holding a decoded field separator, so the local code `687` is in no modeled slot and the record is
733
+ left with no code to key on at all, only an unvalidated wire value nobody wrote. A patient name
734
+ loses its given and middle names the same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
274
735
  before it existed the only warnings on those streams were tolerable ones, so a strict parse under a
275
736
  legal profile accepted a truncated value and an emptied test identity. The reading is unchanged: it
276
737
  reports the gained boundary rather than repairing it. **At a LATER boundary it fires and no modeled
@@ -340,154 +801,28 @@ because every such set is by definition non-canonical, so the only warning it us
340
801
  always refused these sets (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap first showed: a
341
802
  message that parsed clean threw when it was serialized back against its own declared delimiters.
342
803
 
343
- ### Several messages in one stream
344
-
345
- A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
346
- splits a parsed stream into them, and each entry carries only its own records, so a patient is only
347
- ever paired with the results that message actually carried:
804
+ ## Contributing
348
805
 
349
- ```ts
350
- import { parseAstmRecords, messages } from "@cosyte/astm";
806
+ Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum.
351
807
 
352
- for (const m of messages(parseAstmRecords(raw))) {
353
- m.patient?.practiceAssignedId; // the P for THIS message
354
- m.results; // the Rs for THIS message
355
- m.delimiters; // the set THIS message's records were read with
356
- }
357
- ```
358
-
359
- The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
360
- so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
361
- across patients:
808
+ A contribution has to clear the same gates CI runs, and every one of them runs locally:
362
809
 
363
- - `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
364
- - `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
365
- one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
366
-
367
- **Both are breaking**, and the second one reaches single-message callers: a lone message carrying
368
- several patients used to answer with the first of them. A stream that is one message with at most one
369
- patient is unchanged, and so is a result-only message with no `P` at all, which still answers
370
- `undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
371
- already names the message.
372
-
373
- Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
374
- you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
375
- byte for instance, opens no message, and the messages either side of it merge back into one, so a
376
- patient can end up holding results that arrived under a different header. The
377
- parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
378
- only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
379
- a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
380
- not gate on the warning count, though, because the records that merged in can raise warnings of
381
- their own.
382
-
383
- Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
384
- records after it are read with the previous set and their fields can be lost rather than merely
385
- misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
386
- the delimiters in force found no field separator in it at all, so the whole line read back as one
387
- field and none of its modeled fields survived. On a result record that is the value, the units and
388
- the status at once, so treat it as a lost result, not a formatting nit. The fields are never
389
- reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
390
- code is safety-critical, and it does not need a mangled header to fire: a lone record written in
391
- another set trips it too.
392
-
393
- **Its absence does not certify that a record was read in its own set**, and this is the important
394
- half. The check tests one of the four delimiter roles, the **field** separator, and only in its
395
- total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
396
- outside it:
397
-
398
- - A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
399
- the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
400
- the value, the units and the status with no warning at all, while the identical record without
401
- that one byte is reported. This also happens **inside** a run of these warnings, so even a run
402
- does not mean every record in it was checked.
403
- - A set differing in the **repeat, component or escape** role usually splits into fields normally,
404
- and the damage then varies. A mis-split component can cost a test identity while the value and
405
- units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
406
- longer merges the rest of the record (it reads as a literal and raises
407
- `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
408
- that is itself a delimiter in force is an opaque
409
- atom, so that delimiter does not split and the value, the units and the status can still go
410
- together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
411
- alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
412
- where the leftmost alignment lets a delimiter split that a competing alignment would have held,
413
- gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
414
- not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
415
- an escape character heading no sequence it can interpret, every later field shifts and a
416
- result's units
417
- and status are read out of slots the other alignment does not put them in: that raises
418
- `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
419
- nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
420
- truncates a value and costs a test identity or a patient name the components that sat after it:
421
- that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
422
- **component** boundary nothing leaves the record and every component after it moves along the
423
- component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
424
- alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
425
- tolerable either.
426
-
427
- All are accepted limits, for two different reasons: widening the field-separator check would mean
428
- deciding which set a record ought to have had, which is the same guess the parser declines to make
429
- elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
430
- written down rather than papered over. Read the warning as "this record definitely lost its
431
- fields", never as "no other record did". If
432
- delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
433
- outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
434
- invalidating what follows it rather than expecting this warning to enumerate the damage.
435
-
436
- An unrecognized type letter also makes the message **kind** unknowable, because the letter that
437
- could not be read may have been the very `Q` that decides it. `classification.kind` is
438
- `indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
439
- says why. A `Q` that was read still wins outright.
440
-
441
- ## Map local codes to LOINC (LIVD, bring-your-own)
442
-
443
- An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
444
- downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
445
- mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
446
- ambiguous code is surfaced as such, **never a guessed LOINC**.
447
-
448
- ```ts
449
- import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
450
-
451
- // Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
452
- const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
453
-
454
- const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
455
- const { annotations, warnings } = applyLivd(msg, catalog);
456
-
457
- annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
458
- warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
810
+ ```bash
811
+ pnpm install
812
+ pnpm run typecheck
813
+ pnpm run lint
814
+ pnpm run test
815
+ pnpm run build
816
+ pnpm run format:check
817
+ pnpm run check:no-emdash
818
+ pnpm run check:no-internal-refs
819
+ pnpm run phi-scan
459
820
  ```
460
821
 
461
- **No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
462
- its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
463
- separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
464
- its license obligations).
465
-
466
- > **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
467
- > to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
468
- > instruments that reuse the same code for different analytes would both match: supply a catalog built
469
- > for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
470
- > surfaced as `ambiguous`, never resolved to a guess.)
471
-
472
- ## The cosyte parser archetype
822
+ Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs and every JSDoc comment say what the software does, never how the change got made.
473
823
 
474
- - **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
475
- spec-clean), so quirks don't propagate downstream on round-trip.
476
- - **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
477
- Tier 3 fatal always.
478
- - **Stable warning codes**: warnings carry stable string codes + positional context; consumers
479
- branch on `w.code`, so renaming a code is a breaking change.
480
- - **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
481
- - **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
482
- - **Immutability**: parsed models are immutable; mutation is via explicit methods.
483
- - **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
484
- through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
485
- to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
486
- default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
487
- - **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
488
- (`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
489
- LOINC / SNOMED / LIVD data is bundled.
824
+ A behavior change needs a test that fails without it. Renaming a published warning or error code is a breaking change; adding one is not, though it can change which streams a `{ strict: true }` parse refuses, so say so.
490
825
 
491
826
  ## License
492
827
 
493
- MIT © Cosyte
828
+ MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).