@cosyte/astm 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -119
- package/README.md +28 -23
- package/dist/index.cjs +57 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +394 -394
- package/dist/index.d.ts +394 -394
- package/dist/index.mjs +57 -57
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
|
|
3
|
+
<img alt="Cosyte: a plus mark set in two overlapping rounded squares, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
|
|
4
|
+
</picture>
|
|
5
|
+
|
|
1
6
|
# @cosyte/astm
|
|
2
7
|
|
|
3
|
-
> ASTM parser, serializer, and builder for Node.js and TypeScript
|
|
8
|
+
> ASTM parser, serializer, and builder for Node.js and TypeScript: **lenient on parse,
|
|
4
9
|
> spec-clean on emit**.
|
|
5
10
|
|
|
6
11
|
`@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
|
|
@@ -32,8 +37,8 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
|
32
37
|
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
33
38
|
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
34
39
|
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
35
|
-
construction, and a delimiter set that fails any of the three conditions readback requires
|
|
36
|
-
character per separator, no `CR`/`LF`, no two the same
|
|
40
|
+
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
41
|
+
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
37
42
|
and lost.
|
|
38
43
|
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
39
44
|
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
@@ -69,7 +74,7 @@ A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>24
|
|
|
69
74
|
## Drive the transport (framed vs raw) + the LTP protocol
|
|
70
75
|
|
|
71
76
|
ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
|
|
72
|
-
vendor
|
|
77
|
+
vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
|
|
73
78
|
cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
|
|
74
79
|
pure protocol reducer with your own socket I/O.
|
|
75
80
|
|
|
@@ -85,7 +90,7 @@ import {
|
|
|
85
90
|
// 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
|
|
86
91
|
const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
|
|
87
92
|
if (framing === "raw") {
|
|
88
|
-
// cobas b121 raw-TCP: no handshake, no frames
|
|
93
|
+
// cobas b121 raw-TCP: no handshake, no frames, parse the record bytes directly.
|
|
89
94
|
parseAstmRecords(rawBytes);
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -95,19 +100,19 @@ function onControlOrFrame(event) {
|
|
|
95
100
|
const { state: next, actions, warnings } = ltpReduce(state, event);
|
|
96
101
|
state = next;
|
|
97
102
|
for (const a of actions) {
|
|
98
|
-
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK
|
|
99
|
-
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK
|
|
103
|
+
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK, only ever for a good frame
|
|
104
|
+
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK, bad checksum ⇒ retransmit
|
|
100
105
|
if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
|
|
101
106
|
}
|
|
102
|
-
void warnings; // ASTM_LTP_
|
|
107
|
+
void warnings; // ASTM_LTP_*, value-free (a code + at most a frame number)
|
|
103
108
|
}
|
|
104
109
|
// Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
|
|
105
110
|
```
|
|
106
111
|
|
|
107
112
|
The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
|
|
108
|
-
codec did not vouch for
|
|
113
|
+
codec did not vouch for (bad checksum, unterminated, or out of sequence) yields `sendNak`, **never** a
|
|
109
114
|
fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
|
|
110
|
-
The interactive contention/timeout/retransmit **timing** is the consumer's
|
|
115
|
+
The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
|
|
111
116
|
state transitions, not the wall-clock timers.
|
|
112
117
|
|
|
113
118
|
## Install
|
|
@@ -130,7 +135,7 @@ patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (th
|
|
|
130
135
|
msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
|
|
131
136
|
```
|
|
132
137
|
|
|
133
|
-
The parser is **lenient by default**
|
|
138
|
+
The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
|
|
134
139
|
produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
|
|
135
140
|
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
136
141
|
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
@@ -138,7 +143,7 @@ record type is surfaced (never dropped), and a missing unit is flagged (never de
|
|
|
138
143
|
### Several messages in one stream
|
|
139
144
|
|
|
140
145
|
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
141
|
-
splits a parsed stream into them, and each entry carries only its own records
|
|
146
|
+
splits a parsed stream into them, and each entry carries only its own records, so a patient is only
|
|
142
147
|
ever paired with the results that message actually carried:
|
|
143
148
|
|
|
144
149
|
```ts
|
|
@@ -168,7 +173,7 @@ already names the message.
|
|
|
168
173
|
## Map local codes to LOINC (LIVD, bring-your-own)
|
|
169
174
|
|
|
170
175
|
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|
|
171
|
-
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message
|
|
176
|
+
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
|
|
172
177
|
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
173
178
|
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
174
179
|
|
|
@@ -187,31 +192,31 @@ warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free)
|
|
|
187
192
|
|
|
188
193
|
**No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
|
|
189
194
|
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
190
|
-
separately-licensed SNOMED CT
|
|
195
|
+
separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
|
|
191
196
|
its license obligations).
|
|
192
197
|
|
|
193
198
|
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
194
199
|
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
195
|
-
> instruments that reuse the same code for different analytes would both match
|
|
200
|
+
> instruments that reuse the same code for different analytes would both match: supply a catalog built
|
|
196
201
|
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
197
202
|
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
198
203
|
|
|
199
204
|
## The cosyte parser archetype
|
|
200
205
|
|
|
201
|
-
- **Postel's Law
|
|
206
|
+
- **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
|
|
202
207
|
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
203
|
-
- **Tiered tolerance
|
|
208
|
+
- **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
204
209
|
Tier 3 fatal always.
|
|
205
|
-
- **Stable warning codes
|
|
210
|
+
- **Stable warning codes**: warnings carry stable string codes + positional context; consumers
|
|
206
211
|
branch on `w.code`, so renaming a code is a breaking change.
|
|
207
|
-
- **Zero runtime dependencies
|
|
208
|
-
- **Dual ESM + CJS
|
|
209
|
-
- **Immutability
|
|
210
|
-
- **Profile system
|
|
212
|
+
- **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
|
|
213
|
+
- **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
|
|
214
|
+
- **Immutability**: parsed models are immutable; mutation is via explicit methods.
|
|
215
|
+
- **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
211
216
|
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
212
217
|
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
213
218
|
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
214
|
-
- **Terminology recognizer, not a dictionary
|
|
219
|
+
- **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
|
|
215
220
|
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
|
|
216
221
|
LOINC / SNOMED / LIVD data is bundled.
|
|
217
222
|
|
package/dist/index.cjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/common/errors.ts
|
|
4
4
|
var FATAL_CODES = {
|
|
5
|
-
/** Input was empty or whitespace-only
|
|
5
|
+
/** Input was empty or whitespace-only: there is nothing to parse. Shared across layers. */
|
|
6
6
|
EMPTY_INPUT: "EMPTY_INPUT",
|
|
7
|
-
/** The first record is not an `H` (header) record
|
|
7
|
+
/** The first record is not an `H` (header) record: an ASTM message must lead with `H`. */
|
|
8
8
|
ASTM_RECORD_NO_HEADER: "ASTM_RECORD_NO_HEADER",
|
|
9
9
|
/** The `H` record is too short to declare the four delimiters (field/repeat/component/escape). */
|
|
10
10
|
ASTM_RECORD_UNDECLARED_DELIMITERS: "ASTM_RECORD_UNDECLARED_DELIMITERS"
|
|
@@ -121,21 +121,21 @@ function primaryCode(u) {
|
|
|
121
121
|
|
|
122
122
|
// src/common/warnings.ts
|
|
123
123
|
var WARNING_CODES = {
|
|
124
|
-
/** A record's type letter is not one of the modeled types
|
|
124
|
+
/** A record's type letter is not one of the modeled types: surfaced as an unsupported record. */
|
|
125
125
|
ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
|
|
126
|
-
/** The header declared delimiters other than the canonical `H
|
|
126
|
+
/** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
|
|
127
127
|
ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
|
|
128
|
-
/** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E
|
|
128
|
+
/** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
|
|
129
129
|
ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
|
|
130
130
|
/**
|
|
131
131
|
* A result value field carried an *unescaped* component delimiter, so it split into more than one
|
|
132
|
-
* component. Both the full raw value and the split are surfaced and this warning fires
|
|
132
|
+
* component. Both the full raw value and the split are surfaced and this warning fires: the
|
|
133
133
|
* ambiguity is never resolved silently into a truncated value.
|
|
134
134
|
*/
|
|
135
135
|
ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT: "ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT",
|
|
136
136
|
/**
|
|
137
137
|
* A result's abnormal-flag field (`R` field 7) carried a letter outside HL7 Table 0078. The flag is
|
|
138
|
-
* surfaced as `undefined
|
|
138
|
+
* surfaced as `undefined`: never dropped, and **never coerced to `normal`** (a clinical error).
|
|
139
139
|
*/
|
|
140
140
|
ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG: "ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG",
|
|
141
141
|
/**
|
|
@@ -145,7 +145,7 @@ var WARNING_CODES = {
|
|
|
145
145
|
ASTM_RECORD_UNDEFINED_RESULT_STATUS: "ASTM_RECORD_UNDEFINED_RESULT_STATUS",
|
|
146
146
|
/**
|
|
147
147
|
* A result's reference-range field (`R` field 6) did not match a recognized form (`low-high`,
|
|
148
|
-
* `<high`, `>low`). The text is surfaced verbatim as `unparsed
|
|
148
|
+
* `<high`, `>low`). The text is surfaced verbatim as `unparsed`: a bound is **never fabricated**.
|
|
149
149
|
*/
|
|
150
150
|
ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE: "ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE",
|
|
151
151
|
/**
|
|
@@ -154,25 +154,25 @@ var WARNING_CODES = {
|
|
|
154
154
|
*/
|
|
155
155
|
ASTM_RECORD_UNITS_ABSENT: "ASTM_RECORD_UNITS_ABSENT",
|
|
156
156
|
/**
|
|
157
|
-
* A `C` (comment) record had no valid preceding `H`/`P`/`O`/`R` parent
|
|
157
|
+
* A `C` (comment) record had no valid preceding `H`/`P`/`O`/`R` parent: an **orphan**. The comment
|
|
158
158
|
* is attached to the message root (`attachedToRoot: true`) and surfaced, **never dropped**.
|
|
159
159
|
*/
|
|
160
160
|
ASTM_RECORD_ORPHAN_COMMENT: "ASTM_RECORD_ORPHAN_COMMENT",
|
|
161
161
|
/**
|
|
162
162
|
* A `YYYYMMDDHHMMSS` timestamp had an odd digit run that truncates a two-digit component in half
|
|
163
163
|
* (e.g. a partial day/hour). The raw run is preserved and the structured value stops at the last
|
|
164
|
-
* **complete** component
|
|
164
|
+
* **complete** component: the dangling digit is **never zero-filled into a fabricated time**.
|
|
165
165
|
*/
|
|
166
166
|
ASTM_RECORD_PARTIAL_TIMESTAMP: "ASTM_RECORD_PARTIAL_TIMESTAMP",
|
|
167
167
|
/**
|
|
168
168
|
* A `Q` (request-information) record carried a request-information status code (field 13). The code
|
|
169
169
|
* *set* is `[OSS-derived / paywalled]` with no publicly-groundable enumeration, so the parser
|
|
170
170
|
* interprets **none** of them: the status is surfaced verbatim and this value-free warning flags that
|
|
171
|
-
* it was passed through **uninterpreted
|
|
171
|
+
* it was passed through **uninterpreted**, never mapped to a guessed meaning.
|
|
172
172
|
*/
|
|
173
173
|
ASTM_RECORD_UNINTERPRETED_QUERY_STATUS: "ASTM_RECORD_UNINTERPRETED_QUERY_STATUS",
|
|
174
174
|
/**
|
|
175
|
-
* A message carried **both** a `Q` (request) and an `R` (result) record
|
|
175
|
+
* A message carried **both** a `Q` (request) and an `R` (result) record: a contradictory shape. The
|
|
176
176
|
* message is classified `host-query` (the `Q` **dominates**, so it is never read as a result set) and
|
|
177
177
|
* this warning flags the anomaly. Positional context only; no field value.
|
|
178
178
|
*/
|
|
@@ -181,7 +181,7 @@ var WARNING_CODES = {
|
|
|
181
181
|
* A later `H` (header) record declared a **different** delimiter set from the one in force, and the
|
|
182
182
|
* parser **followed it**: that header and every record after it are read with the newly-declared
|
|
183
183
|
* delimiters, until the next `H`. Records already read keep the set that was in force when they were
|
|
184
|
-
* read
|
|
184
|
+
* read: a redeclaration never reinterprets bytes that have already been consumed.
|
|
185
185
|
*
|
|
186
186
|
* A stream carrying several messages back to back is ordinary, and a header that simply **repeats**
|
|
187
187
|
* the delimiters already in force is a no-op that warns nothing. This code fires only when the set
|
|
@@ -190,13 +190,13 @@ var WARNING_CODES = {
|
|
|
190
190
|
*/
|
|
191
191
|
ASTM_RECORD_DELIMITERS_REDECLARED: "ASTM_RECORD_DELIMITERS_REDECLARED",
|
|
192
192
|
/**
|
|
193
|
-
* A later `H` (header) record could not declare a usable delimiter set
|
|
193
|
+
* A later `H` (header) record could not declare a usable delimiter set: it was too short, or the
|
|
194
194
|
* field separator it named also appeared among the other three, leaving the four roles
|
|
195
195
|
* indistinguishable. The delimiters **already in force are kept** and every record is still surfaced;
|
|
196
196
|
* a set is never guessed and no record is dropped.
|
|
197
197
|
*
|
|
198
198
|
* The same condition on the *first* header is unrecoverable and remains the
|
|
199
|
-
* `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal
|
|
199
|
+
* `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal: there is no earlier set to fall back to.
|
|
200
200
|
*/
|
|
201
201
|
ASTM_RECORD_UNREADABLE_REDECLARATION: "ASTM_RECORD_UNREADABLE_REDECLARATION",
|
|
202
202
|
/**
|
|
@@ -204,7 +204,7 @@ var WARNING_CODES = {
|
|
|
204
204
|
* (see `src/profiles/`). The original warning is **never dropped**: its code moves to
|
|
205
205
|
* {@link AstmRecordWarning.toleratedCode}, the warning is re-badged `PROFILE_QUIRK_APPLIED` with
|
|
206
206
|
* `expected: true` and the tolerating profile named, so a consumer can filter known, grounded noise
|
|
207
|
-
* while the fact of the deviation
|
|
207
|
+
* while the fact of the deviation, and where it was, survives. A profile can only ever reach this
|
|
208
208
|
* path for a **non-safety-critical** code (enforced at profile-definition time); a safety-critical
|
|
209
209
|
* deviation (a result value, flag, status, patient identifier, code system, or a frame-integrity
|
|
210
210
|
* warning) can **never** be tolerated, so it can never be re-badged here.
|
|
@@ -214,28 +214,28 @@ var WARNING_CODES = {
|
|
|
214
214
|
function unknownRecordType(position) {
|
|
215
215
|
return {
|
|
216
216
|
code: WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
|
|
217
|
-
message: "Unrecognized record type
|
|
217
|
+
message: "Unrecognized record type, surfaced verbatim as an unsupported record.",
|
|
218
218
|
position
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
function nonStandardDelimiters(position) {
|
|
222
222
|
return {
|
|
223
223
|
code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
|
|
224
|
-
message: "Header declared non-canonical delimiters
|
|
224
|
+
message: "Header declared non-canonical delimiters, read from the header and honored.",
|
|
225
225
|
position
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
function delimitersRedeclared(position) {
|
|
229
229
|
return {
|
|
230
230
|
code: WARNING_CODES.ASTM_RECORD_DELIMITERS_REDECLARED,
|
|
231
|
-
message: "A later header declared different delimiters
|
|
231
|
+
message: "A later header declared different delimiters, honored from that header onward; earlier records keep the set they were read with.",
|
|
232
232
|
position
|
|
233
233
|
};
|
|
234
234
|
}
|
|
235
235
|
function unreadableRedeclaration(position) {
|
|
236
236
|
return {
|
|
237
237
|
code: WARNING_CODES.ASTM_RECORD_UNREADABLE_REDECLARATION,
|
|
238
|
-
message: "A later header could not declare a usable delimiter set
|
|
238
|
+
message: "A later header could not declare a usable delimiter set, the delimiters already in force were kept.",
|
|
239
239
|
position
|
|
240
240
|
};
|
|
241
241
|
}
|
|
@@ -249,63 +249,63 @@ function unknownEscapeSequence(position) {
|
|
|
249
249
|
function ambiguousValueSplit(position) {
|
|
250
250
|
return {
|
|
251
251
|
code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
|
|
252
|
-
message: "Result value contained an unescaped component delimiter
|
|
252
|
+
message: "Result value contained an unescaped component delimiter, full raw value and split both surfaced.",
|
|
253
253
|
position
|
|
254
254
|
};
|
|
255
255
|
}
|
|
256
256
|
function undefinedAbnormalFlag(position) {
|
|
257
257
|
return {
|
|
258
258
|
code: WARNING_CODES.ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG,
|
|
259
|
-
message: "Abnormal flag is not in HL7 Table 0078
|
|
259
|
+
message: "Abnormal flag is not in HL7 Table 0078, surfaced as undefined, never as normal.",
|
|
260
260
|
position
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
263
|
function undefinedResultStatus(position) {
|
|
264
264
|
return {
|
|
265
265
|
code: WARNING_CODES.ASTM_RECORD_UNDEFINED_RESULT_STATUS,
|
|
266
|
-
message: "Result status is not a recognized status letter
|
|
266
|
+
message: "Result status is not a recognized status letter, surfaced as undefined.",
|
|
267
267
|
position
|
|
268
268
|
};
|
|
269
269
|
}
|
|
270
270
|
function unparseableReferenceRange(position) {
|
|
271
271
|
return {
|
|
272
272
|
code: WARNING_CODES.ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE,
|
|
273
|
-
message: "Reference range did not match a recognized form
|
|
273
|
+
message: "Reference range did not match a recognized form, surfaced verbatim, no bound invented.",
|
|
274
274
|
position
|
|
275
275
|
};
|
|
276
276
|
}
|
|
277
277
|
function unitsAbsent(position) {
|
|
278
278
|
return {
|
|
279
279
|
code: WARNING_CODES.ASTM_RECORD_UNITS_ABSENT,
|
|
280
|
-
message: "Numeric result value carried no units
|
|
280
|
+
message: "Numeric result value carried no units, never defaulted, guessed, or converted.",
|
|
281
281
|
position
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
284
|
function orphanComment(position) {
|
|
285
285
|
return {
|
|
286
286
|
code: WARNING_CODES.ASTM_RECORD_ORPHAN_COMMENT,
|
|
287
|
-
message: "Comment had no valid preceding parent
|
|
287
|
+
message: "Comment had no valid preceding parent, attached to the message root, never dropped.",
|
|
288
288
|
position
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
291
|
function partialTimestamp(position) {
|
|
292
292
|
return {
|
|
293
293
|
code: WARNING_CODES.ASTM_RECORD_PARTIAL_TIMESTAMP,
|
|
294
|
-
message: "Timestamp digit run truncates a component
|
|
294
|
+
message: "Timestamp digit run truncates a component, preserved verbatim, never zero-filled.",
|
|
295
295
|
position
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
function uninterpretedQueryStatus(position) {
|
|
299
299
|
return {
|
|
300
300
|
code: WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS,
|
|
301
|
-
message: "Query request-information status surfaced verbatim
|
|
301
|
+
message: "Query request-information status surfaced verbatim, code set paywalled, never interpreted.",
|
|
302
302
|
position
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
function ambiguousMessageKind(position) {
|
|
306
306
|
return {
|
|
307
307
|
code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND,
|
|
308
|
-
message: "Message carried both a Q (request) and an R (result) record
|
|
308
|
+
message: "Message carried both a Q (request) and an R (result) record, classified host-query; Q dominates.",
|
|
309
309
|
position
|
|
310
310
|
};
|
|
311
311
|
}
|
|
@@ -569,12 +569,12 @@ var FRAME_WARNING_CODES = {
|
|
|
569
569
|
* A frame's two-hex-char checksum did not match the modulo-256 sum recomputed over its bytes. The
|
|
570
570
|
* frame is surfaced with `trusted: false` and its text is **never merged** into a reassembled
|
|
571
571
|
* record (default **warn** in lenient mode, escalated to a thrown error in `strict`). Corruption is
|
|
572
|
-
* never silently trusted
|
|
572
|
+
* never silently trusted: the "checksums are routinely not validated" claim was refuted; we
|
|
573
573
|
* validate.
|
|
574
574
|
*/
|
|
575
575
|
ASTM_FRAME_BAD_CHECKSUM: "ASTM_FRAME_BAD_CHECKSUM",
|
|
576
576
|
/**
|
|
577
|
-
* A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`)
|
|
577
|
+
* A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`): a frame was
|
|
578
578
|
* possibly dropped. The stream is **never silently concatenated across the gap** as if contiguous;
|
|
579
579
|
* the in-progress record is tainted and not emitted as a clean reassembly.
|
|
580
580
|
*/
|
|
@@ -588,7 +588,7 @@ var FRAME_WARNING_CODES = {
|
|
|
588
588
|
ASTM_FRAME_UNTERMINATED: "ASTM_FRAME_UNTERMINATED",
|
|
589
589
|
/**
|
|
590
590
|
* A frame's record text exceeded the 240-byte limit without a split. The frame is still surfaced
|
|
591
|
-
* (and, if its checksum validates, reassembled)
|
|
591
|
+
* (and, if its checksum validates, reassembled): the deviation is flagged, not silently dropped
|
|
592
592
|
* (warn in lenient, thrown in `strict`).
|
|
593
593
|
*/
|
|
594
594
|
ASTM_FRAME_OVERSIZE: "ASTM_FRAME_OVERSIZE"
|
|
@@ -596,28 +596,28 @@ var FRAME_WARNING_CODES = {
|
|
|
596
596
|
function frameBadChecksum(position) {
|
|
597
597
|
return {
|
|
598
598
|
code: FRAME_WARNING_CODES.ASTM_FRAME_BAD_CHECKSUM,
|
|
599
|
-
message: "Frame checksum mismatch
|
|
599
|
+
message: "Frame checksum mismatch, surfaced untrusted, never merged into a record.",
|
|
600
600
|
position
|
|
601
601
|
};
|
|
602
602
|
}
|
|
603
603
|
function frameSequenceGap(position) {
|
|
604
604
|
return {
|
|
605
605
|
code: FRAME_WARNING_CODES.ASTM_FRAME_SEQUENCE_GAP,
|
|
606
|
-
message: "Frame number out of sequence
|
|
606
|
+
message: "Frame number out of sequence, possible dropped frame, never silently bridged.",
|
|
607
607
|
position
|
|
608
608
|
};
|
|
609
609
|
}
|
|
610
610
|
function frameUnterminated(position) {
|
|
611
611
|
return {
|
|
612
612
|
code: FRAME_WARNING_CODES.ASTM_FRAME_UNTERMINATED,
|
|
613
|
-
message: "Unterminated frame
|
|
613
|
+
message: "Unterminated frame, partial bytes surfaced untrusted, no partial record invented.",
|
|
614
614
|
position
|
|
615
615
|
};
|
|
616
616
|
}
|
|
617
617
|
function frameOversize(position) {
|
|
618
618
|
return {
|
|
619
619
|
code: FRAME_WARNING_CODES.ASTM_FRAME_OVERSIZE,
|
|
620
|
-
message: "Frame text exceeded the 240-byte limit without a split
|
|
620
|
+
message: "Frame text exceeded the 240-byte limit without a split, flagged, never dropped.",
|
|
621
621
|
position
|
|
622
622
|
};
|
|
623
623
|
}
|
|
@@ -626,21 +626,21 @@ function frameOversize(position) {
|
|
|
626
626
|
var LTP_WARNING_CODES = {
|
|
627
627
|
/**
|
|
628
628
|
* The transport detector could not tell a framed stream from a raw (unframed) one from its leading
|
|
629
|
-
* byte
|
|
629
|
+
* byte: it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
|
|
630
630
|
* never guessing silently into data loss; a profile override forces the mode. (cobas b121 drops
|
|
631
|
-
* framing over TCP; cobas 4800 / Iguana retain it
|
|
631
|
+
* framing over TCP; cobas 4800 / Iguana retain it, both realities exist, so an unrecognizable lead
|
|
632
632
|
* is defaulted, not assumed.)
|
|
633
633
|
*/
|
|
634
634
|
ASTM_LTP_AMBIGUOUS_TRANSPORT: "ASTM_LTP_AMBIGUOUS_TRANSPORT",
|
|
635
635
|
/**
|
|
636
|
-
* A control event arrived in a protocol state that did not expect it
|
|
636
|
+
* A control event arrived in a protocol state that did not expect it: e.g. an inbound `ACK`/`NAK`
|
|
637
637
|
* at a receiver (which sends, never receives, those), or an `ENQ` mid-transfer. The event is
|
|
638
638
|
* surfaced and handled defensively (an unexpected `ACK`/`NAK` is **never** read as acceptance of
|
|
639
639
|
* data); it never advances the transfer as if valid.
|
|
640
640
|
*/
|
|
641
641
|
ASTM_LTP_UNEXPECTED_EVENT: "ASTM_LTP_UNEXPECTED_EVENT",
|
|
642
642
|
/**
|
|
643
|
-
* The receiver rejected a received frame with a `NAK` instead of accepting it
|
|
643
|
+
* The receiver rejected a received frame with a `NAK` instead of accepting it: because its
|
|
644
644
|
* checksum failed, it was unterminated, or its frame number was out of sequence. The frame's text
|
|
645
645
|
* is **never** appended to the record and the transfer does **not** advance; the receiver awaits the
|
|
646
646
|
* sender's retransmit. This is the protocol-level face of the frame codec's fail-safe: a bad frame
|
|
@@ -651,20 +651,20 @@ var LTP_WARNING_CODES = {
|
|
|
651
651
|
function ltpAmbiguousTransport() {
|
|
652
652
|
return {
|
|
653
653
|
code: LTP_WARNING_CODES.ASTM_LTP_AMBIGUOUS_TRANSPORT,
|
|
654
|
-
message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter
|
|
654
|
+
message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter, defaulted to framed; override with a profile to force raw."
|
|
655
655
|
};
|
|
656
656
|
}
|
|
657
657
|
function ltpUnexpectedEvent(frameNumber) {
|
|
658
658
|
return {
|
|
659
659
|
code: LTP_WARNING_CODES.ASTM_LTP_UNEXPECTED_EVENT,
|
|
660
|
-
message: "Control event arrived in a state that did not expect it
|
|
660
|
+
message: "Control event arrived in a state that did not expect it, handled defensively, never read as acceptance.",
|
|
661
661
|
...frameNumber !== void 0 ? { frameNumber } : {}
|
|
662
662
|
};
|
|
663
663
|
}
|
|
664
664
|
function ltpFrameRejected(frameNumber) {
|
|
665
665
|
return {
|
|
666
666
|
code: LTP_WARNING_CODES.ASTM_LTP_FRAME_REJECTED,
|
|
667
|
-
message: "Frame rejected (bad checksum, unterminated, or out of sequence)
|
|
667
|
+
message: "Frame rejected (bad checksum, unterminated, or out of sequence), NAK sent, retransmit expected, never accepted.",
|
|
668
668
|
...frameNumber !== void 0 ? { frameNumber } : {}
|
|
669
669
|
};
|
|
670
670
|
}
|
|
@@ -772,7 +772,7 @@ function validateTolerations(tolerate, profileName) {
|
|
|
772
772
|
}
|
|
773
773
|
if (isSafetyCriticalCode(t.code)) {
|
|
774
774
|
throw new AstmProfileDefinitionError(
|
|
775
|
-
`Profile '${profileName}' may not tolerate '${t.code}'
|
|
775
|
+
`Profile '${profileName}' may not tolerate '${t.code}': it is a safety-critical warning code (a result value / flag / status / range / units, patient or comment context, message-kind ambiguity, code system, or a frame / LTP integrity warning). A profile quiets benign structural noise, never a deviation that could change a clinical reading or corrupt the wire: it can never make a bad checksum "ok" or a cancelled result read "final."`,
|
|
776
776
|
profileName
|
|
777
777
|
);
|
|
778
778
|
}
|
|
@@ -815,14 +815,14 @@ function defineAstmProfile(opts) {
|
|
|
815
815
|
// src/profiles/default.ts
|
|
816
816
|
var defaultProfile = defineAstmProfile({
|
|
817
817
|
name: "default",
|
|
818
|
-
description: "Conservative baseline
|
|
818
|
+
description: "Conservative baseline: tolerates nothing; every deviation surfaces as a warning.",
|
|
819
819
|
tolerate: []
|
|
820
820
|
});
|
|
821
821
|
|
|
822
822
|
// src/profiles/reference-corpus.ts
|
|
823
823
|
var referenceCorpus = defineAstmProfile({
|
|
824
824
|
name: "referenceCorpus",
|
|
825
|
-
description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only
|
|
825
|
+
description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only: the value is preserved verbatim, never a safety-critical value.",
|
|
826
826
|
provenance: {
|
|
827
827
|
source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
|
|
828
828
|
reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
|
|
@@ -1086,7 +1086,7 @@ function buildRecord(line2, recordIndex, d, warnings) {
|
|
|
1086
1086
|
case "Q":
|
|
1087
1087
|
return buildQuery(recordIndex, fields, warnings);
|
|
1088
1088
|
// `M` (manufacturer) and `S` (scientific) are vendor-defined free-form data surfaced VERBATIM and
|
|
1089
|
-
// NEVER interpreted into typed clinical fields
|
|
1089
|
+
// NEVER interpreted into typed clinical fields: the exact wire line is preserved for round-trip.
|
|
1090
1090
|
case "M":
|
|
1091
1091
|
return { type: "M", recordIndex, fields, rawLine: line2 };
|
|
1092
1092
|
case "S":
|
|
@@ -1119,7 +1119,7 @@ function buildPatient(recordIndex, fields, warnings) {
|
|
|
1119
1119
|
recordIndex,
|
|
1120
1120
|
fields,
|
|
1121
1121
|
...definedString("seq", fieldScalar(astmField(fields, 2))),
|
|
1122
|
-
// The three patient identifiers stay DISTINCT
|
|
1122
|
+
// The three patient identifiers stay DISTINCT: practice-assigned (3), laboratory-assigned (4),
|
|
1123
1123
|
// and a third ID (5) never collapse into one; conflating them is the primary misfiling path.
|
|
1124
1124
|
...definedString("practiceAssignedId", fieldScalar(astmField(fields, 3))),
|
|
1125
1125
|
...definedString("laboratoryAssignedId", fieldScalar(astmField(fields, 4))),
|
|
@@ -1142,7 +1142,7 @@ function buildOrder(recordIndex, fields) {
|
|
|
1142
1142
|
...hasContent(utidField) ? { universalTestId: recognizeUniversalTestId(utidField.components) } : {},
|
|
1143
1143
|
// Priority (6), action code (~12), and report type (~26) are surfaced verbatim. The `~` field
|
|
1144
1144
|
// indices and the code sets are `[OSS-derived]` (paywalled), so they are never mapped to a
|
|
1145
|
-
// guessed meaning
|
|
1145
|
+
// guessed meaning: see the JSDoc on `OrderRecord`.
|
|
1146
1146
|
...definedString("priority", fieldScalar(astmField(fields, 6))),
|
|
1147
1147
|
...definedString("actionCode", fieldScalar(astmField(fields, 12))),
|
|
1148
1148
|
...definedString("reportType", fieldScalar(astmField(fields, 26)))
|
|
@@ -1541,7 +1541,7 @@ var AMBIGUOUS_CODES = {
|
|
|
1541
1541
|
ASTM_AMBIGUOUS_MULTI_PATIENT: "ASTM_AMBIGUOUS_MULTI_PATIENT"
|
|
1542
1542
|
};
|
|
1543
1543
|
var AstmAmbiguousStreamError = class extends Error {
|
|
1544
|
-
/** The stable discriminant
|
|
1544
|
+
/** The stable discriminant: see {@link AMBIGUOUS_CODES}. */
|
|
1545
1545
|
code;
|
|
1546
1546
|
/** Where the ambiguity became visible: the second header, or the second `P`. Value-free. */
|
|
1547
1547
|
position;
|
|
@@ -1686,14 +1686,14 @@ function defineLivdCatalog(entries) {
|
|
|
1686
1686
|
var LIVD_WARNING_CODES = {
|
|
1687
1687
|
/**
|
|
1688
1688
|
* A record carried a vendor/local test code, but the consumer-supplied LIVD catalog held **no**
|
|
1689
|
-
* entry for it. The code stays surfaced verbatim and the mapping is `unmapped
|
|
1689
|
+
* entry for it. The code stays surfaced verbatim and the mapping is `unmapped`: a LOINC is
|
|
1690
1690
|
* **never** guessed. Purely advisory: the raw code and value are untouched.
|
|
1691
1691
|
*/
|
|
1692
1692
|
ASTM_LIVD_UNMAPPED_CODE: "ASTM_LIVD_UNMAPPED_CODE",
|
|
1693
1693
|
/**
|
|
1694
1694
|
* A vendor/local test code matched **more than one distinct LOINC** in the catalog (e.g. the same
|
|
1695
1695
|
* transmission code used by two devices for different analytes). The mapping is `ambiguous` and the
|
|
1696
|
-
* candidate LOINCs are surfaced for inspection, but **none is chosen
|
|
1696
|
+
* candidate LOINCs are surfaced for inspection, but **none is chosen**: refusing to pick is the
|
|
1697
1697
|
* fail-safe, since guessing wrong mis-identifies the test.
|
|
1698
1698
|
*/
|
|
1699
1699
|
ASTM_LIVD_AMBIGUOUS_MAPPING: "ASTM_LIVD_AMBIGUOUS_MAPPING"
|
|
@@ -1701,14 +1701,14 @@ var LIVD_WARNING_CODES = {
|
|
|
1701
1701
|
function livdUnmappedCode(position) {
|
|
1702
1702
|
return {
|
|
1703
1703
|
code: LIVD_WARNING_CODES.ASTM_LIVD_UNMAPPED_CODE,
|
|
1704
|
-
message: "Reported test code had no LIVD mapping
|
|
1704
|
+
message: "Reported test code had no LIVD mapping, surfaced unmapped, never a guessed LOINC.",
|
|
1705
1705
|
position
|
|
1706
1706
|
};
|
|
1707
1707
|
}
|
|
1708
1708
|
function livdAmbiguousMapping(position) {
|
|
1709
1709
|
return {
|
|
1710
1710
|
code: LIVD_WARNING_CODES.ASTM_LIVD_AMBIGUOUS_MAPPING,
|
|
1711
|
-
message: "Reported test code mapped to multiple distinct LOINCs
|
|
1711
|
+
message: "Reported test code mapped to multiple distinct LOINCs, surfaced ambiguous, never one guessed.",
|
|
1712
1712
|
position
|
|
1713
1713
|
};
|
|
1714
1714
|
}
|
|
@@ -2152,7 +2152,7 @@ function encodeFrame(text, frameNumber, isFinal) {
|
|
|
2152
2152
|
}
|
|
2153
2153
|
function composeAstmFrames(records, options = {}) {
|
|
2154
2154
|
if (records.length === 0) {
|
|
2155
|
-
throw new AstmFrameEncodeError("Cannot frame an empty record list
|
|
2155
|
+
throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
|
|
2156
2156
|
}
|
|
2157
2157
|
const out = [];
|
|
2158
2158
|
let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
|
|
@@ -2160,7 +2160,7 @@ function composeAstmFrames(records, options = {}) {
|
|
|
2160
2160
|
const bytes = toBytes(record);
|
|
2161
2161
|
if (bytes.length === 0) {
|
|
2162
2162
|
throw new AstmFrameEncodeError(
|
|
2163
|
-
"Cannot frame an empty record
|
|
2163
|
+
"Cannot frame an empty record: a frame must carry record bytes.",
|
|
2164
2164
|
recordIndex
|
|
2165
2165
|
);
|
|
2166
2166
|
}
|
|
@@ -2199,7 +2199,7 @@ function concatBytes3(chunks) {
|
|
|
2199
2199
|
}
|
|
2200
2200
|
|
|
2201
2201
|
// src/index.ts
|
|
2202
|
-
var VERSION = "0.0.
|
|
2202
|
+
var VERSION = "0.0.6";
|
|
2203
2203
|
|
|
2204
2204
|
exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
|
|
2205
2205
|
exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
|