@cosyte/astm 0.0.3 → 0.0.5

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
@@ -1,6 +1,6 @@
1
1
  # @cosyte/astm
2
2
 
3
- > ASTM parser, serializer, and builder for Node.js and TypeScript — **lenient on parse,
3
+ > ASTM parser, serializer, and builder for Node.js and TypeScript: **lenient on parse,
4
4
  > spec-clean on emit**.
5
5
 
6
6
  `@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
@@ -32,7 +32,9 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
32
32
  - **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
33
33
  delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
34
34
  frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
35
- construction.
35
+ construction, and a delimiter set that fails any of the three conditions readback requires (one
36
+ character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
37
+ and lost.
36
38
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
37
39
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
38
40
  ever altering a value, behind a definition-time safety gate that refuses to tolerate any result
@@ -67,7 +69,7 @@ A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>24
67
69
  ## Drive the transport (framed vs raw) + the LTP protocol
68
70
 
69
71
  ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
70
- vendor** — the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
72
+ vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
71
73
  cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
72
74
  pure protocol reducer with your own socket I/O.
73
75
 
@@ -83,7 +85,7 @@ import {
83
85
  // 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
84
86
  const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
85
87
  if (framing === "raw") {
86
- // cobas b121 raw-TCP: no handshake, no frames — parse the record bytes directly.
88
+ // cobas b121 raw-TCP: no handshake, no frames, parse the record bytes directly.
87
89
  parseAstmRecords(rawBytes);
88
90
  }
89
91
 
@@ -93,19 +95,19 @@ function onControlOrFrame(event) {
93
95
  const { state: next, actions, warnings } = ltpReduce(state, event);
94
96
  state = next;
95
97
  for (const a of actions) {
96
- if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK — only ever for a good frame
97
- if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK — bad checksum ⇒ retransmit
98
+ if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK, only ever for a good frame
99
+ if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK, bad checksum ⇒ retransmit
98
100
  if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
99
101
  }
100
- void warnings; // ASTM_LTP_* — value-free (a code + at most a frame number)
102
+ void warnings; // ASTM_LTP_*, value-free (a code + at most a frame number)
101
103
  }
102
104
  // Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
103
105
  ```
104
106
 
105
107
  The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
106
- codec did not vouch for — bad checksum, unterminated, or out of sequence — yields `sendNak`, **never** a
108
+ codec did not vouch for (bad checksum, unterminated, or out of sequence) yields `sendNak`, **never** a
107
109
  fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
108
- The interactive contention/timeout/retransmit **timing** is the consumer's — this layer models the
110
+ The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
109
111
  state transitions, not the wall-clock timers.
110
112
 
111
113
  ## Install
@@ -128,15 +130,45 @@ patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (th
128
130
  msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
129
131
  ```
130
132
 
131
- The parser is **lenient by default** — vendor quirks become warnings, not failures — and refuses to
133
+ The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
132
134
  produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
133
135
  record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
134
136
  `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
135
137
 
138
+ ### Several messages in one stream
139
+
140
+ 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, so a patient is only
142
+ ever paired with the results that message actually carried:
143
+
144
+ ```ts
145
+ import { parseAstmRecords, messages } from "@cosyte/astm";
146
+
147
+ for (const m of messages(parseAstmRecords(raw))) {
148
+ m.patient?.practiceAssignedId; // the P for THIS message
149
+ m.results; // the Rs for THIS message
150
+ m.delimiters; // the set THIS message's records were read with
151
+ }
152
+ ```
153
+
154
+ The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
155
+ so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
156
+ across patients:
157
+
158
+ - `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
159
+ - `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
160
+ one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
161
+
162
+ **Both are breaking**, and the second one reaches single-message callers: a lone message carrying
163
+ several patients used to answer with the first of them. A stream that is one message with at most one
164
+ patient is unchanged, and so is a result-only message with no `P` at all, which still answers
165
+ `undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
166
+ already names the message.
167
+
136
168
  ## Map local codes to LOINC (LIVD, bring-your-own)
137
169
 
138
170
  An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
139
- downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message — the
171
+ downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
140
172
  mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
141
173
  ambiguous code is surfaced as such, **never a guessed LOINC**.
142
174
 
@@ -155,31 +187,31 @@ warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free)
155
187
 
156
188
  **No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
157
189
  its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
158
- separately-licensed SNOMED CT — so the package ships no terminology data and you bring the catalog (and
190
+ separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
159
191
  its license obligations).
160
192
 
161
193
  > **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
162
194
  > to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
163
- > instruments that reuse the same code for different analytes would both match — supply a catalog built
195
+ > instruments that reuse the same code for different analytes would both match: supply a catalog built
164
196
  > for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
165
197
  > surfaced as `ambiguous`, never resolved to a guess.)
166
198
 
167
199
  ## The cosyte parser archetype
168
200
 
169
- - **Postel's Law** — liberal parser (lenient default + warnings), conservative serializer (always
201
+ - **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
170
202
  spec-clean), so quirks don't propagate downstream on round-trip.
171
- - **Tiered tolerance** — Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
203
+ - **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
172
204
  Tier 3 fatal always.
173
- - **Stable warning codes** — warnings carry stable string codes + positional context; consumers
205
+ - **Stable warning codes**: warnings carry stable string codes + positional context; consumers
174
206
  branch on `w.code`, so renaming a code is a breaking change.
175
- - **Zero runtime dependencies** — Node stdlib only (healthcare integrations vet every dependency).
176
- - **Dual ESM + CJS** — built with `tsup`, validated with `attw`.
177
- - **Immutability** — parsed models are immutable; mutation is via explicit methods.
178
- - **Profile system** — a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
207
+ - **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
208
+ - **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
209
+ - **Immutability**: parsed models are immutable; mutation is via explicit methods.
210
+ - **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
179
211
  through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
180
212
  to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
181
213
  default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
182
- - **Terminology recognizer, not a dictionary** — LIVD-aware LOINC recognition is bring-your-own
214
+ - **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
183
215
  (`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
184
216
  LOINC / SNOMED / LIVD data is bundled.
185
217