@cosyte/astm 0.0.1
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 +358 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/index.cjs +2071 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3861 -0
- package/dist/index.d.ts +3861 -0
- package/dist/index.mjs +1977 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +100 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
Versions and publishing are managed with [Changesets](https://github.com/changesets/changesets);
|
|
8
|
+
this file is maintained by hand (Changesets handles the version bump and publish only).
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
The first pre-alpha release (`0.0.1`) will ship the initial public API surface. The package begins
|
|
13
|
+
its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Project scaffold from the shared `@cosyte/*` parser template: the canonical toolchain (TypeScript
|
|
18
|
+
ES2023 + strict rigor via `@cosyte/tsconfig`, ESLint 10 + type-checked `typescript-eslint` via
|
|
19
|
+
`@cosyte/eslint-config`, Prettier via `@cosyte/prettier-config`, Vitest 4 + v8 coverage via
|
|
20
|
+
`@cosyte/vitest-config`, dual ESM + CJS build via `tsup` + `@cosyte/tsup-config`, `attw` publish
|
|
21
|
+
gate), thin callers of the reusable `cosyte/.github` CI/release workflows, Changesets on the
|
|
22
|
+
`0.0.x` ladder, and the property-based conformance harness from `@cosyte/test-utils`.
|
|
23
|
+
- **Record foundation (ASTM-1, roadmap Phase 1).** The record-content layer: parse an ASTM/CLSI-LIS02
|
|
24
|
+
record stream and pull result value + units + flag in one line.
|
|
25
|
+
- `parseAstmRecords(raw, opts?)` → an immutable, deeply-frozen `AstmMessage`; `results(msg)` /
|
|
26
|
+
`patient(msg)` typed extractors.
|
|
27
|
+
- **Delimiter self-declaration** — the four delimiters (field / repeat / component / escape) are
|
|
28
|
+
read from each `H` record, never hardcoded, with ASTM's `\`=repeat and `&`=escape mapping.
|
|
29
|
+
- **Escape codec** — `&F&`/`&S&`/`&R&`/`&E&` are decoded via escape-aware split-then-decode, so a
|
|
30
|
+
value containing an escaped component delimiter reads as **one** component (the documented
|
|
31
|
+
silent-misread class the OSS references exhibit). Re-escaping is deferred to the emit phase (P7).
|
|
32
|
+
- Modeled records: `H` (delimiter provenance), `P` (identity — practice-assigned ID and
|
|
33
|
+
laboratory-assigned ID kept **distinct**), `O` (accession + Universal Test ID), `R` (all 14
|
|
34
|
+
fields; value / units / flags / status surfaced **raw**), `L`. Unknown record types surface as
|
|
35
|
+
`unsupported` records with a warning, never dropped.
|
|
36
|
+
- `/common` value layer: delimiter model, escape codec, precision-preserving `YYYYMMDDHHMMSS` date
|
|
37
|
+
value (no-UTC, partial dates are not errors), Universal Test ID code-system provenance
|
|
38
|
+
recognition (`[OSS-derived]` field order), the deep-freeze base, and the warning/fatal registry.
|
|
39
|
+
- Fatal codes: `EMPTY_INPUT` (shared), `ASTM_RECORD_NO_HEADER`, `ASTM_RECORD_UNDECLARED_DELIMITERS`.
|
|
40
|
+
Warning codes: `ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`,
|
|
41
|
+
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` — all carry stable code +
|
|
42
|
+
value-free positional context.
|
|
43
|
+
- **Fail-safe on an unescaped component delimiter in a result value:** the full raw value and the
|
|
44
|
+
component split are both surfaced and an `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` warning fires — the
|
|
45
|
+
primary `value` is never silently truncated to the first component.
|
|
46
|
+
- `scripts/phi-scan.ts` extended toward the P-record loci (name + DOB, delimiter-aware); synthetic
|
|
47
|
+
fixtures declared in `scripts/phi-allow-list.txt`.
|
|
48
|
+
- Public exports replace the scaffold stubs: `parseAstmRecords`, `results`, `patient`,
|
|
49
|
+
`AstmParseError`, `AstmStrictError`, the record/value model types, and the `WARNING_CODES` /
|
|
50
|
+
`FATAL_CODES` registries.
|
|
51
|
+
- **Safety-critical result semantics (ASTM-2, roadmap Phase 2).** The raw `R`-record letters that
|
|
52
|
+
Phase 1 surfaced are now modeled into fail-safe semantics, under one rule — _never a confident wrong
|
|
53
|
+
value_. The raw strings (`abnormalFlags`, `resultStatus`, `referenceRange`, `units`) still coexist
|
|
54
|
+
with the modeled views; nothing is collapsed or reconciled.
|
|
55
|
+
- **Abnormal flags (field 7) → HL7 Table 0078.** `interpretAbnormalFlag()` and the `flag` field on
|
|
56
|
+
`ResultRecord` model the full value set: `L`/`H`, panic `LL`/`HH`, off-scale `<`/`>`, `N`, `A`/`AA`,
|
|
57
|
+
the **directional** significant-change `U` (up) / `D` (down) — _not_ units/delta — `B`/`W`, and
|
|
58
|
+
microbiology `S`/`R`/`I`. An **unrecognized** flag is surfaced as `meaning: "undefined"` with an
|
|
59
|
+
`ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning — **never dropped, never coerced to `normal`**.
|
|
60
|
+
- **Result status (field 9).** `interpretResultStatus()` and the always-present `status` field model
|
|
61
|
+
`F`/`C`/`P`/`R`/`S`/`I`/`X`, with **`C` correction** (`supersedes: true`) and **`X` cancel**
|
|
62
|
+
(`cancelled: true`) so a superseded/cancelled result can **never** read as current — `isActiveFinal`
|
|
63
|
+
is `true` only for a plain `F`. An **absent** status is typed `unspecified` (never assumed `final`);
|
|
64
|
+
an unrecognized one is `undefined` + `ASTM_RECORD_UNDEFINED_RESULT_STATUS`.
|
|
65
|
+
- **Reference range (field 6).** `parseReferenceRange()` and the `range` field parse `low-high`
|
|
66
|
+
(closed), `<high` (open-low), and `>low` (open-high); bounds are surfaced as **verbatim numeric
|
|
67
|
+
text** (never coerced to floats). The range is read from the **full field text**, so a
|
|
68
|
+
component-delimited value (`low^high`) is preserved verbatim and read as `unparsed` — never
|
|
69
|
+
truncated to a single bound. An unparseable range is `kind: "unparsed"` +
|
|
70
|
+
`ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE` — **no bound is fabricated**. The exact delimiter is
|
|
71
|
+
`[OSS-derived]` pending the purchased CLSI LIS02-A2 (roadmap §10 Q1).
|
|
72
|
+
- **Units discipline (field 5).** A _numeric_ result value with no units raises
|
|
73
|
+
`ASTM_RECORD_UNITS_ABSENT`; units are vendor free text (not UCUM) and are **never defaulted,
|
|
74
|
+
guessed, or converted**.
|
|
75
|
+
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG`,
|
|
76
|
+
`ASTM_RECORD_UNDEFINED_RESULT_STATUS`, `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`,
|
|
77
|
+
`ASTM_RECORD_UNITS_ABSENT` — all value-free (code + record/field index only).
|
|
78
|
+
- **Patient/order identity depth, comments, and partial-timestamp hardening (ASTM-3, roadmap Phase 3).**
|
|
79
|
+
The misfiling-prevention slice: model the identity that a result files against, and the context that
|
|
80
|
+
qualifies it.
|
|
81
|
+
- **Full patient (`P`) identity.** The **practice-assigned ID (field 3)**, the **laboratory-assigned
|
|
82
|
+
ID (field 4)**, and a **third patient ID (field 5)** are modeled as **distinct** fields that never
|
|
83
|
+
collapse into one — conflating them is the primary result-misfiling path. Adds mother's maiden name
|
|
84
|
+
(field 7) alongside the existing name components (field 6), birthdate (field 8), and sex (field 9).
|
|
85
|
+
- **Full order (`O`).** `priority` (field 6), `actionCode` (field ~12), and `reportType` (field ~26)
|
|
86
|
+
are surfaced **verbatim** on top of the existing specimen/accession + Universal Test ID. The `~`
|
|
87
|
+
field indices and the code sets are `[OSS-derived]` (paywalled) — never mapped to a guessed meaning.
|
|
88
|
+
- **The `C` (comment) record.** Modeled as `source` (field 3), `text` (field 4, component-capable —
|
|
89
|
+
the full text is surfaced plus the component split, never truncated), and `commentType` (field 5).
|
|
90
|
+
Each comment is **attached by position** to the immediately-preceding `H`/`P`/`O`/`R` parent
|
|
91
|
+
(`parentIndex`); consecutive comments share that parent. **Fail-safe:** an **orphan** comment with no
|
|
92
|
+
valid parent is attached to the message root (`attachedToRoot: true`) with an
|
|
93
|
+
`ASTM_RECORD_ORPHAN_COMMENT` warning — **never silently dropped**. New extractors `comments(msg)` /
|
|
94
|
+
`commentsFor(msg, record)` / `orders(msg)`, and the pure `attachComments()` attachment pass.
|
|
95
|
+
- **Comment-type codes are `[OSS-derived]`.** `I` (instrument) is the only value seen in the
|
|
96
|
+
permissively-licensed real transcripts; `G`/`T`/`P` are defined only in the paywalled CLSI LIS02-A2
|
|
97
|
+
and are **not** interpreted — `commentType` is surfaced raw, never mapped to a guessed meaning.
|
|
98
|
+
- **Partial-timestamp hardening.** A `YYYYMMDDHHMMSS` value with an odd digit run that truncates a
|
|
99
|
+
two-digit component (lengths 5/7/9/11/13) sets `AstmDate.truncated`, is preserved verbatim in `raw`,
|
|
100
|
+
and stops at the last **complete** component — the dangling digit is **never zero-filled into a
|
|
101
|
+
fabricated time**. A caller surfaces this as a value-free `ASTM_RECORD_PARTIAL_TIMESTAMP` warning
|
|
102
|
+
(P field 8, R fields 12/13). No timezone is modeled — times stay instrument-local, never assumed UTC.
|
|
103
|
+
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_ORPHAN_COMMENT`,
|
|
104
|
+
`ASTM_RECORD_PARTIAL_TIMESTAMP` — value-free (code + record/field index only).
|
|
105
|
+
- `scripts/phi-scan.ts` extended toward the mother's-maiden locus (P field 7), on top of the existing
|
|
106
|
+
name (field 6) + DOB (field 8) detection; synthetic fixtures declared in `scripts/phi-allow-list.txt`.
|
|
107
|
+
- **Query (`Q`) + host-query flow + `M`/`S` surfaced verbatim (ASTM-4, roadmap Phase 4).** Completes the
|
|
108
|
+
record grammar — **the record-content layer is now feature-complete.**
|
|
109
|
+
- **The `Q` (Request Information) record.** Modeled at the public ASTM E1394 field positions:
|
|
110
|
+
`startingRangeId` (field 3) and `endingRangeId` (field 4) surfaced as the **full verbatim field**
|
|
111
|
+
(never truncated to a component), the Universal Test ID (field 5, same caret structure as `O`/`R`),
|
|
112
|
+
and `requestInformationStatus` (field 13) surfaced **verbatim**. The range component structure, the
|
|
113
|
+
`ALL` universal-query keyword (`queriesAllTests`), and the request-information status code set are
|
|
114
|
+
all **`[OSS-derived / paywalled]`** (roadmap §10 Q3) — surfaced, flagged, and **never interpreted or
|
|
115
|
+
guessed**. New `query(msg)` extractor.
|
|
116
|
+
- **The host-query flow.** Every message is classified up front (`msg.classification`): an `H/P/Q/L`
|
|
117
|
+
**request** is `host-query`, an `R`-bearing message is `results`, an `O`-only message is `orders`,
|
|
118
|
+
else `indeterminate`. **Fail-safe:** the `Q` **dominates** — a `Q`-bearing message is a request and
|
|
119
|
+
is **never** read as a result set, even when a result record is also present (a contradiction flagged
|
|
120
|
+
with `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`). Gate on `classification.isHostQueryRequest`. Pure
|
|
121
|
+
`classifyMessage(records)` exported.
|
|
122
|
+
- **`M` (manufacturer) + `S` (scientific) records surfaced verbatim.** Vendor-defined free-form
|
|
123
|
+
QC / calibration / maintenance data, preserved byte-for-byte on `record.rawLine` and **never**
|
|
124
|
+
interpreted into typed clinical fields — a QC value can never be read as a patient result. Round-trip
|
|
125
|
+
byte-identical.
|
|
126
|
+
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`
|
|
127
|
+
(a Q request-information status surfaced verbatim; the code set is paywalled, so it is passed through
|
|
128
|
+
uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND` — both value-free (code + position only).
|
|
129
|
+
- `AstmMessage` gains a `classification` field; `AstmRecord` gains `QueryRecord` / `ManufacturerRecord`
|
|
130
|
+
/ `ScientificRecord` members (an unknown type letter is still an `UnsupportedRecord`, never dropped).
|
|
131
|
+
- **E1381/CLSI-LIS01 frame codec (ASTM-5, roadmap Phase 5).** The **low-level framing layer** begins —
|
|
132
|
+
a separate, independent layer from the record layer, sharing only the payload boundary. `src/frames/`
|
|
133
|
+
decodes a framed byte stream into frames + reassembled record bytes; `src/common/` and `src/records/`
|
|
134
|
+
are untouched.
|
|
135
|
+
- `decodeAstmFrames(bytes, opts?)` → `{ records: readonly Uint8Array[]; frames: readonly AstmFrame[];
|
|
136
|
+
warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS <CR><LF>`.
|
|
137
|
+
- **Modulo-256 checksum** over the bytes after `STX` up to and **including** the `ETB`/`ETX`
|
|
138
|
+
terminator, two hex chars — **verified on decode, emitted uppercase, accepted lowercase** (a real
|
|
139
|
+
vendor quirk). `computeChecksum` / `toChecksumHex` / `parseChecksumHex` exported.
|
|
140
|
+
- **Frame-number `0`–`7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
|
|
141
|
+
reassembly** — text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
|
|
142
|
+
is intermediate / `ETX` final. `parseFramedAstm(bytes, opts?)` composes the framing and record layers
|
|
143
|
+
at the edge (decode → reassemble trusted records → `parseAstmRecords`).
|
|
144
|
+
- **Fail-safe (byte-level, safety-critical):** a **checksum mismatch** surfaces the frame flagged
|
|
145
|
+
`trusted: false` and **never merges** it into a record (default warn in lenient / thrown in strict —
|
|
146
|
+
the "checksums are routinely not validated" claim was _refuted_: we validate); a **frame-number gap**
|
|
147
|
+
warns and is **never silently bridged**; an **unterminated** frame surfaces the partial bytes
|
|
148
|
+
untrusted and **invents no partial record**; an **oversize** (>240) frame is flagged, never dropped.
|
|
149
|
+
- New `ASTM_FRAME_*` warning registry (a **second** registry alongside `ASTM_RECORD_*`, sharing only
|
|
150
|
+
the `EMPTY_INPUT` fatal; snapshot locked): `ASTM_FRAME_BAD_CHECKSUM`, `ASTM_FRAME_SEQUENCE_GAP`,
|
|
151
|
+
`ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE` — every warning **value-free**, carrying a **frame
|
|
152
|
+
number + byte offset** only, never the record bytes a frame holds. `{ strict: true }` throws
|
|
153
|
+
`AstmFrameStrictError`.
|
|
154
|
+
- **Fuzz gate (required, part of `verify`):** a `fast-check` target over the codec — arbitrary /
|
|
155
|
+
truncated / mixed / control-char-laden bytes never crash, hang, or OOM; they degrade to a typed
|
|
156
|
+
error or a value-free warning. Plus property tests: N-frame reassembly equals the single-frame form,
|
|
157
|
+
and every trusted frame's recomputed checksum matches its declared value.
|
|
158
|
+
- New types/exports: `AstmFrame`, `FrameChecksum`, `FrameTerminator`, `FrameOptions`,
|
|
159
|
+
`DecodeAstmFramesResult`, `FramedAstmResult`, `AstmFramePosition`, `AstmFrameWarning`,
|
|
160
|
+
`FrameWarningCode`, `FRAME_WARNING_CODES`.
|
|
161
|
+
- **Transport variants + pure LTP protocol reducer (ASTM-6, roadmap Phase 6).** The **LTP protocol
|
|
162
|
+
layer** — `src/ltp/` — sits above the frame codec: transport auto-detection plus a deterministic,
|
|
163
|
+
socket-free session state machine. No live I/O: the consumer owns the wire and clock; this layer
|
|
164
|
+
decides.
|
|
165
|
+
- **Transport auto-detection.** `detectFraming(bytes, opts?)` → `{ framing: "framed" | "raw";
|
|
166
|
+
defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, and the cobas 4800 /
|
|
167
|
+
iNTERFACEWARE Iguana framed-over-TCP reality); a leading bare record letter (`H`/`P`/`O`/`R`/`C`/
|
|
168
|
+
`Q`/`M`/`S`/`L`) ⇒ **raw** (the cobas b121 raw-TCP reality — framing dropped, records streamed
|
|
169
|
+
directly). An unrecognizable lead **defaults to framed and warns**
|
|
170
|
+
(`ASTM_LTP_AMBIGUOUS_TRANSPORT`), never guessing silently into data loss; an `override` forces the
|
|
171
|
+
mode (the Phase-8 profile hook).
|
|
172
|
+
- **Pure receiver-side reducer.** `ltpReduce(state, event)` → `{ state, actions, warnings }`, seeded
|
|
173
|
+
by `ltpInitialState()`. Events are the four LTP control signals (`enq`/`ack`/`nak`/`eot`) plus a
|
|
174
|
+
codec-decoded `frame`; actions are `sendAck` / `sendNak` / `sendEot` / `deliverRecord`. It models
|
|
175
|
+
the LIS01-A2 establishment → transfer → termination phases as `neutral ⇄ transfer`, reassembling
|
|
176
|
+
`ETB…ETX` runs into delivered records and tracking the `0`–`7` frame sequence.
|
|
177
|
+
- **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for — a
|
|
178
|
+
**bad checksum**, an **unterminated** frame, or one **out of sequence** — is answered with `NAK`,
|
|
179
|
+
**never** a fabricated positive `ACK`, and its bytes are **never** appended to a record or
|
|
180
|
+
delivered. A `NAK` drives **retransmit, not acceptance** (`ASTM_LTP_FRAME_REJECTED`). A duplicate
|
|
181
|
+
of the last-accepted frame is idempotently re-`ACK`ed without re-appending; a partial record open
|
|
182
|
+
on an `EOT` or `ENQ` restart is discarded, never delivered as if whole.
|
|
183
|
+
- New `ASTM_LTP_*` warning registry (a **third** registry alongside `ASTM_RECORD_*` / `ASTM_FRAME_*`;
|
|
184
|
+
value-free, carrying at most a frame number): `ASTM_LTP_AMBIGUOUS_TRANSPORT`,
|
|
185
|
+
`ASTM_LTP_UNEXPECTED_EVENT`, `ASTM_LTP_FRAME_REJECTED`.
|
|
186
|
+
- Property tests: the reducer **never emits `ACK` after an untrusted frame**; a full `ENQ → frames →
|
|
187
|
+
EOT` session **reassembles exactly the source records**; a **raw-TCP stream equals its framed
|
|
188
|
+
twin**. Plus the transport-control control bytes `ASTM_ENQ` / `ASTM_ACK` / `ASTM_NAK` / `ASTM_EOT`.
|
|
189
|
+
- New types/exports: `detectFraming`, `AstmFraming`, `DetectFramingOptions`, `DetectFramingResult`,
|
|
190
|
+
`ltpInitialState`, `ltpReduce`, `LtpPhase`, `LtpState`, `LtpEvent`, `LtpAction`, `LtpTransition`,
|
|
191
|
+
`AstmLtpWarning`, `LtpWarningCode`, `LTP_WARNING_CODES`, `ltpAmbiguousTransport`,
|
|
192
|
+
`ltpUnexpectedEvent`, `ltpFrameRejected`.
|
|
193
|
+
- **Spec-clean serializers + builders — both layers (ASTM-7, roadmap Phase 7).** The **emit** side: the
|
|
194
|
+
conservative inverse of the parser and the frame codec, so **round-trip fidelity holds by
|
|
195
|
+
construction**. Postel's Law's second half — liberal on parse, strict on emit.
|
|
196
|
+
- **Record serializer.** `serializeAstmRecords(msg | records)` and `serializeAstmRecord(record)` emit a
|
|
197
|
+
`CR`-terminated stream with the **canonical** `H|\^&` delimiters and every embedded delimiter
|
|
198
|
+
re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec — the escape char is
|
|
199
|
+
encoded **first** (`&` → `&E&`), then the field / component / repeat delimiters (`&F&`/`&S&`/`&R&`) —
|
|
200
|
+
so a value containing a delimiter (a titre `1^40` → `1&S&40`) can never break framing and reads back
|
|
201
|
+
as **one** component. A source parsed with **non-canonical** delimiters is **normalized** to the
|
|
202
|
+
canonical set on emit (vendor-delimiter round-tripping is a Phase-8 profile concern). The header's
|
|
203
|
+
delimiter declaration is emitted **literally** (never escaped); `M`/`S` records are re-emitted
|
|
204
|
+
**byte-identically** from `rawLine`.
|
|
205
|
+
- **Message builder.** `buildAstmMessage(input)` constructs a spec-clean stream from typed input under
|
|
206
|
+
the **never-fabricate** discipline: it emits **only** the values the caller supplied — an omitted
|
|
207
|
+
field is left empty, **never a defaulted clinical value** (an unset result status reads back as
|
|
208
|
+
`unspecified`, never `final`; units / abnormal flags / patient IDs are never defaulted). The
|
|
209
|
+
**structure** — record type letters, the canonical delimiter declaration, per-record-type sequence
|
|
210
|
+
counters, the `L` terminator — is **computed, not guessed** (a sequence number may be overridden).
|
|
211
|
+
- **Frame encoder.** `composeAstmFrames(records, opts?)` is the exact inverse of `decodeAstmFrames`:
|
|
212
|
+
it wraps reassembled record bytes into `<STX> FN text <ETB|ETX> CS <CR><LF>` frames with the
|
|
213
|
+
modulo-256 **checksum** and the `0`–`7` **frame number** **computed** (never accepted-as-given or
|
|
214
|
+
faked; emitted uppercase), numbered continuously across the stream (start `1`, roll over `7 → 0`),
|
|
215
|
+
and every record over **240** text bytes **split** `ETB…ETX` (the seven control bytes never counted).
|
|
216
|
+
`serializeFramedAstm(msg | records)` composes both emit layers at the edge — the mirror of
|
|
217
|
+
`parseFramedAstm`.
|
|
218
|
+
- **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF` — which
|
|
219
|
+
no ASTM escape can encode — is refused with an `AstmSerializeError` (`ASTM_EMIT_UNENCODABLE_VALUE`)
|
|
220
|
+
rather than emitted into a corrupted wire; an empty record or empty record list is an
|
|
221
|
+
`AstmFrameEncodeError` (`ASTM_FRAME_EMPTY_RECORD`), never an empty frame.
|
|
222
|
+
- **Round-trip proven.** The shared archetype `roundTripProperty` is now **live** (serialize is the
|
|
223
|
+
idempotent inverse of parse); Tier-3 golden files round-trip every synthetic fixture through both the
|
|
224
|
+
record and framing layers (structural equality of the decoded field tree, zero frame warnings); and
|
|
225
|
+
`decodeAstmFrames(composeAstmFrames(x)).records ≡ x`.
|
|
226
|
+
- `HeaderRecord` gains an additive `rawLine` field (the escape char living inside the `\^&` definition
|
|
227
|
+
defeats the generic escape-aware tokenizer, so the raw header is the reliable source for both
|
|
228
|
+
delimiter reading and re-serialization). New exports: `serializeAstmRecords`, `serializeAstmRecord`,
|
|
229
|
+
`serializeField`, `encodeComponent`, `AstmSerializeError`, `buildAstmMessage` (+ `AstmRecordInput`,
|
|
230
|
+
`MessageInput`, `HeaderInput`, `PatientInput`, `PatientNameInput`, `OrderInput`, `ResultInput`,
|
|
231
|
+
`CommentInput`, `QueryInput`, `VerbatimInput`), `composeAstmFrames`, `AstmFrameEncodeError`,
|
|
232
|
+
`ComposeFramesOptions`, `serializeFramedAstm`.
|
|
233
|
+
- **Vendor profile system — engine + registry + quirk tolerance + definition-time safety gate
|
|
234
|
+
(ASTM-8, roadmap Phase 8).** `src/profiles/` mirrors the sibling `@cosyte/hl7` `defineProfile` /
|
|
235
|
+
`@cosyte/ccda` `defineCcdaProfile` shape: `name` / `lineage` / `describe()` / `extends`-merge, a
|
|
236
|
+
provenance-backed built-in registry, a runtime tolerance transform, and a definition-time safety gate.
|
|
237
|
+
- `defineAstmProfile(opts)` builds a frozen, immutable profile declaring the **non-safety-critical**
|
|
238
|
+
warning codes a class of streams is expected to trip (each with a grounded `rationale`), plus an
|
|
239
|
+
optional `transport` override (`"framed"`/`"raw"`) — the raw-vs-framed-TCP knob a consumer feeds to
|
|
240
|
+
`detectFraming(bytes, { override })` for a stream whose leading byte would auto-detect the wrong way.
|
|
241
|
+
- **A profile never alters an extracted value.** The transform (`applyAstmProfileToWarnings`, run last
|
|
242
|
+
in `parseAstmRecords`) only ever re-badges a warning it _expects_ to the new `PROFILE_QUIRK_APPLIED`
|
|
243
|
+
code (flagged `expected: true`, carrying the original `toleratedCode` and position) — Postel's Law
|
|
244
|
+
with a receipt: nothing is dropped, and a spec-clean message parses byte-identically with or without
|
|
245
|
+
a profile.
|
|
246
|
+
- **The safety gate is default-deny and total.** Only four benign, value-preserving record codes are
|
|
247
|
+
tolerable (`ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`, `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
|
|
248
|
+
`ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries — record,
|
|
249
|
+
frame (`ASTM_FRAME_*`), and LTP (`ASTM_LTP_*`) — is safety-critical and refused at definition time**
|
|
250
|
+
with an `AstmProfileDefinitionError`. A profile therefore can never make a bad checksum "ok," a
|
|
251
|
+
cancelled result read "final," or quiet a wrong value / flag / status / range / units / patient or
|
|
252
|
+
comment context / message-kind ambiguity. Any warning code added in a future phase is
|
|
253
|
+
safety-critical **by default** until deliberately added to the allow-list.
|
|
254
|
+
- `parseAstmRecords(raw, { profile })` accepts an explicit profile; `{ profile: null }` opts out of
|
|
255
|
+
the process-scoped default (`setDefaultAstmProfile`); an `expected` quirk does **not** escalate in
|
|
256
|
+
`strict` mode. `AstmMessage` gains an additive `profile?: { name, lineage }` attribution.
|
|
257
|
+
- **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus` — a
|
|
258
|
+
**non-vendor**, evidence-backed profile grounded firsthand in the redistributable OSS reference
|
|
259
|
+
corpus (`kxepal/python-astm` `codec.py` (BSD) + `senaite.astm`, which split on raw delimiters and
|
|
260
|
+
never un-escape `&F&`/`&S&`/`&R&`/`&E&`), tolerating only the resulting non-standard-escape
|
|
261
|
+
_syntactic_ noise (the value is preserved byte-for-byte). **Named per-vendor profiles**
|
|
262
|
+
(cobas / Sysmex / …) are **deferred** (`REAL-CORPUS`): the engine fully supports them, but no public
|
|
263
|
+
vendor-attributed quirk document grounds a named one.
|
|
264
|
+
- New warning code `PROFILE_QUIRK_APPLIED` (record registry). New exports: `defineAstmProfile`,
|
|
265
|
+
`AstmProfileDefinitionError`, `astmProfiles`, `getAstmProfile`, `listAstmProfiles`,
|
|
266
|
+
`setDefaultAstmProfile`, `getDefaultAstmProfile`, `applyAstmProfile`, `applyAstmProfileToWarnings`,
|
|
267
|
+
`resolveProfileTransport`, `profileQuirkApplied`, `SAFETY_CRITICAL_CODES`, `TOLERABLE_CODES`,
|
|
268
|
+
`ALL_ASTM_WARNING_CODES`, `isSafetyCriticalCode`, and the `AstmProfile`, `DefineAstmProfileOptions`,
|
|
269
|
+
`AstmQuirkTolerance`, `AstmQuirkMatch`, `AstmProfileProvenance`, `AnyAstmWarningCode` types.
|
|
270
|
+
- **LIVD-aware LOINC recognition — bring-your-own catalog, zero bundled terminology data (ASTM-9,
|
|
271
|
+
roadmap Phase 9).** The `src/terminology/` layer maps an analyzer's local test code (the Universal
|
|
272
|
+
Test ID's vendor/local code on `R`/`O` records) to a standard LOINC via a **consumer-supplied** IICC
|
|
273
|
+
LIVD ("LOINC to Vendor IVD") catalog — **additive, advisory, and never a guessed LOINC** (a wrong
|
|
274
|
+
LOINC mis-identifies a test).
|
|
275
|
+
- `defineLivdCatalog(entries)` builds an immutable, frozen catalog indexed by the **Vendor Analyte
|
|
276
|
+
Code** (the vendor transmission code the instrument sends), grounded firsthand on the IICC LIVD
|
|
277
|
+
digital format / HL7 LIVD IG; `catalog.lookup(code)` returns `mapped` (one LOINC), `unmapped` (a
|
|
278
|
+
miss), or `ambiguous` (a code matching more than one distinct LOINC — surfaced, **never resolved**).
|
|
279
|
+
- `applyLivd(msg, catalog)` produces a **separate** layer of per-`R`/`O` `LivdAnnotation`s and never
|
|
280
|
+
mutates, alters, or drops the raw reported code/value; a catalog hit is labeled `derived: true`
|
|
281
|
+
(`source: "livd"`), an inline LOINC already on the wire is surfaced `source: "wire"` (never
|
|
282
|
+
overwritten by the catalog), and a miss/conflict is `unmapped`/`ambiguous` with a **value-free**
|
|
283
|
+
warning — a LOINC is **never** fabricated. `lookupLivdForRecord(record, catalog)` annotates one
|
|
284
|
+
record.
|
|
285
|
+
- **No LOINC / SNOMED / LIVD data is bundled** (roadmap §5). Firsthand: LOINC is © Regenstrief —
|
|
286
|
+
redistributable only _with its attribution notice_, not public-domain; and the public CDC LIVD file
|
|
287
|
+
is a **SARS-CoV-2-specific** publication that also carries separately-licensed SNOMED CT, not a
|
|
288
|
+
general-analyte, public-domain catalog. The package stays a structural recognizer, not a dictionary:
|
|
289
|
+
the consumer supplies the LIVD data (and owns its license obligations).
|
|
290
|
+
- New `ASTM_LIVD_*` warning registry (`ASTM_LIVD_UNMAPPED_CODE`, `ASTM_LIVD_AMBIGUOUS_MAPPING`) — a
|
|
291
|
+
fourth, self-contained registry, deliberately outside the profile safety gate's universe (a LIVD
|
|
292
|
+
non-mapping is a post-parse advisory, not a parse-time deviation a profile could tolerate). New
|
|
293
|
+
exports: `defineLivdCatalog`, `applyLivd`, `lookupLivdForRecord`, `LIVD_WARNING_CODES`,
|
|
294
|
+
`livdUnmappedCode`, `livdAmbiguousMapping`, and the `LivdCatalog`, `LivdEntry`, `LivdLookup`,
|
|
295
|
+
`LivdAnnotation`, `LivdMapping`, `LivdResult`, `AstmLivdWarning`, `LivdWarningCode` types.
|
|
296
|
+
- **Release hardening (ASTM-10, roadmap Phase 10 — the final phase).** Publish-readiness for the now
|
|
297
|
+
feature-complete parser: coverage, fuzz, firsthand differential testing, the full docs spine, and a
|
|
298
|
+
proven release shape. No new runtime API.
|
|
299
|
+
- **Differential conformance vs [python-astm][pa]** (BSD-3-Clause reference codec, commit
|
|
300
|
+
`4170ce0c`), grounded **firsthand** in `test/differential/`: outputs captured once from the
|
|
301
|
+
reference (`generate-reference-vectors.py` → `reference-vectors.json`; **no reference code
|
|
302
|
+
vendored**), then asserted against `@cosyte/astm` on three shared paths — the **modulo-256
|
|
303
|
+
checksum**, the **record field/component split** (escape-free, non-header), and a
|
|
304
|
+
**cross-implementation frame decode** (python encodes + splits → our decoder verifies every
|
|
305
|
+
checksum and reassembles the exact record bytes). The **deliberate divergences** are asserted on
|
|
306
|
+
purpose: we un-escape `&F&`/`&S&`/`&R&`/`&E&` (python leaves them literal), we validate the frame
|
|
307
|
+
checksum (python does not verify on decode), and we classify the `Q` host-query (python has no
|
|
308
|
+
model). CI needs no Python — only the captured JSON.
|
|
309
|
+
- **Per-directory ≥ 90 coverage gating extended to the whole `src/` surface** — `frames`, `ltp`,
|
|
310
|
+
and `terminology` now gate per-dir alongside `common`/`records`/`profiles` (on top of the global
|
|
311
|
+
gate), so the release bar holds directory by directory, not just in aggregate.
|
|
312
|
+
- **Record-tokenizer fuzz** (`test/property/records-fuzz.property.test.ts`) — the companion to the
|
|
313
|
+
frame-codec fuzz: arbitrary / truncated / delimiter- and escape-laden input into
|
|
314
|
+
`parseAstmRecords` never crashes, hangs, or OOMs; lenient mode only ever throws a sanctioned
|
|
315
|
+
Tier-3 fatal, strict only `AstmStrictError`, and every warning carries a registered code. Both
|
|
316
|
+
fuzz suites scale via `ASTM_FUZZ_RUNS`, driven up nightly by a scheduled **Fuzz** workflow
|
|
317
|
+
(`.github/workflows/fuzz.yml`) and runnable on demand via `pnpm test:fuzz`.
|
|
318
|
+
- **Publish dry-run proven release-shaped:** `attw` all-green (per-condition ESM/CJS types), a new
|
|
319
|
+
`smoke` gate (`scripts/smoke.mjs`) that imports the **built** ESM and requires the **built** CJS
|
|
320
|
+
entry and parses a result through each (now wired into `verify.sh`), and an `npm publish
|
|
321
|
+
--dry-run` pack inspection (10 files — `dist/` + `README`/`LICENSE`/`CHANGELOG`/`package.json`,
|
|
322
|
+
no `src` or tests). Zero runtime dependencies; MIT.
|
|
323
|
+
- **Full Diátaxis docs spine + honesty docs.** New `docs-content/limitations.md` (**What it does —
|
|
324
|
+
and does not do**: no live I/O, units are verbatim free text not UCUM, no bundled terminology
|
|
325
|
+
dictionary, `M`/`S` verbatim, the archived-standard status, and the MIT-vs-CLSI license posture)
|
|
326
|
+
and `docs-content/architecture.md` (the two independent layers and their payload boundary); the
|
|
327
|
+
**Guides** page is now real how-to recipes (was a placeholder), and the intro / troubleshooting /
|
|
328
|
+
concepts status blocks are refreshed to the feature-complete state. Every ` ```ts runnable `
|
|
329
|
+
example is executed by the doc/code-agreement gate.
|
|
330
|
+
|
|
331
|
+
[pa]: https://github.com/kxepal/python-astm
|
|
332
|
+
|
|
333
|
+
### Changed
|
|
334
|
+
|
|
335
|
+
- **Breaking (pre-alpha):** the archetype stub `parseAstm` / `ParsedAstm` is replaced by
|
|
336
|
+
`parseAstmRecords` / `AstmMessage`; the placeholder `WARNING_CODES` / `FATAL_CODES` entries are
|
|
337
|
+
replaced by the real Phase-1 registries.
|
|
338
|
+
|
|
339
|
+
### Deferred (later phases)
|
|
340
|
+
|
|
341
|
+
- **Named per-vendor profiles** (cobas / Sysmex / ADVIA / Mindray / Snibe) stay `REAL-CORPUS`-gated —
|
|
342
|
+
the Phase-8 engine supports them (tolerate + transport override), but no public vendor-attributed
|
|
343
|
+
quirk document grounds a named one. **No bundled terminology
|
|
344
|
+
dictionary** — LIVD-aware LOINC recognition is bring-your-own by design (Phase 9); the package ships
|
|
345
|
+
no LOINC / SNOMED / LIVD data and mapping quality is the consumer's catalog. The LTP reducer remains
|
|
346
|
+
a pure state machine — no live I/O: wiring it to a real `SerialPort`/`net.Socket` (and the
|
|
347
|
+
interactive contention/timeout/retransmit **timing**) is a thin consumer adapter, and the standard's
|
|
348
|
+
exact numeric timeouts / retry counts are deferred (we model transitions, not timers).
|
|
349
|
+
|
|
350
|
+
### Deprecated
|
|
351
|
+
|
|
352
|
+
### Removed
|
|
353
|
+
|
|
354
|
+
### Fixed
|
|
355
|
+
|
|
356
|
+
### Security
|
|
357
|
+
|
|
358
|
+
[Unreleased]: https://github.com/cosyte/astm/commits/main
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cosyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# @cosyte/astm
|
|
2
|
+
|
|
3
|
+
> ASTM parser, serializer, and builder for Node.js and TypeScript — **lenient on parse,
|
|
4
|
+
> spec-clean on emit**.
|
|
5
|
+
|
|
6
|
+
`@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
|
|
7
|
+
parser that turns real-world, vendor-quirky input into **warnings** rather than failures, paired with
|
|
8
|
+
a serializer that always emits spec-clean output (Postel's Law). It mirrors the API shape of the
|
|
9
|
+
reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
10
|
+
|
|
11
|
+
> **Status:** pre-alpha (`0.0.x`), not yet published to npm. Phase 1 ships the **record** layer
|
|
12
|
+
> (`H`/`P`/`O`/`R`/`L` read, delimiter self-declaration, escape decode); Phase 2 adds **safety-critical
|
|
13
|
+
> result semantics** — HL7 Table 0078 abnormal flags, result status (with correction `C` / cancel `X`),
|
|
14
|
+
> reference-range parsing, and the units-absent discipline; Phase 3 adds **identity depth, comments, and
|
|
15
|
+
> timestamp hardening** — the three distinct patient IDs, mother's maiden name, full order fields, the
|
|
16
|
+
> `C` comment record attached by position (orphans surfaced, never dropped), and partial timestamps
|
|
17
|
+
> preserved and flagged, never zero-filled. Phase 4 adds the **`Q` request-information record + the
|
|
18
|
+
> host-query flow** (a `Q`-bearing message is classified a request, **never** read as a result set) and
|
|
19
|
+
> the **`M`/`S` records surfaced verbatim** (vendor QC/calibration data, never interpreted into clinical
|
|
20
|
+
> fields) — the **record-content layer is now feature-complete**. Phase 5 adds the **E1381/CLSI-LIS01
|
|
21
|
+
> frame codec**: `decodeAstmFrames` decodes a framed byte stream into frames + reassembled record
|
|
22
|
+
> bytes, **verifies the modulo-256 checksum** (a bad frame is surfaced untrusted and never merged),
|
|
23
|
+
> tracks **frame-number sequencing** (a gap is never silently bridged), and **reassembles** the
|
|
24
|
+
> 240-byte-limited multi-frame records; `parseFramedAstm` composes the framing and record layers at the
|
|
25
|
+
> edge. Phase 6 adds the **transport layer**: `detectFraming` auto-detects framed (serial / cobas 4800 /
|
|
26
|
+
> Iguana) vs raw (cobas b121, framing dropped) streams, and `ltpReduce` is a **pure, socket-free**
|
|
27
|
+
> receiver-side `ENQ`/`ACK`/`NAK`/`EOT` state machine whose one rule mirrors `mllp`'s ACK-failsafe — a
|
|
28
|
+
> bad-checksum frame is **NAK**ed, never falsely **ACK**ed. Phase 7 adds the **spec-clean serializers +
|
|
29
|
+
> builders** — `serializeAstmRecords` / `buildAstmMessage` emit canonical `H|\^&` records (embedded
|
|
30
|
+
> delimiters re-escaped, nothing clinical fabricated) and `composeAstmFrames` / `serializeFramedAstm`
|
|
31
|
+
> frame them with **computed** checksums + frame numbers and the 240-byte split, so both layers
|
|
32
|
+
> round-trip by construction. Phase 8 adds the vendor **profile** system — `defineAstmProfile()` builds
|
|
33
|
+
> a provenance-backed profile whose quirk tolerances downgrade _expected_, non-safety-critical
|
|
34
|
+
> deviations to a `PROFILE_QUIRK_APPLIED` warning (values are never altered), guarded by a
|
|
35
|
+
> definition-time safety gate that refuses to tolerate any result value / flag / status / range /
|
|
36
|
+
> units, patient or comment context, message-kind, or frame / LTP integrity warning — a profile can
|
|
37
|
+
> never make a bad checksum "ok" or a cancelled result read "final." Named per-vendor profiles are
|
|
38
|
+
> deferred pending a public vendor-attributed quirk document. Phase 9 adds **LIVD-aware LOINC
|
|
39
|
+
> recognition** — `applyLivd(msg, catalog)` maps an analyzer's local test code to a standard **LOINC**
|
|
40
|
+
> from a **consumer-supplied** IICC LIVD catalog (`defineLivdCatalog`), as an **additive, advisory**
|
|
41
|
+
> annotation that never mutates the raw code/value and **never guesses a LOINC** (an unmapped or
|
|
42
|
+
> ambiguous code surfaces as such). No LOINC / SNOMED / LIVD dictionary is bundled — the package stays a
|
|
43
|
+
> structural recognizer, and the consumer brings their own catalog.
|
|
44
|
+
|
|
45
|
+
## Decode a framed byte stream
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { decodeAstmFrames, parseFramedAstm, results } from "@cosyte/astm";
|
|
49
|
+
|
|
50
|
+
// A raw ASTM byte stream off a serial line or socket.
|
|
51
|
+
const { records, frames, warnings } = decodeAstmFrames(framedBytes);
|
|
52
|
+
frames[0]?.checksum.valid; // the modulo-256 checksum verdict (emitted uppercase, accepted lowercase)
|
|
53
|
+
warnings; // ASTM_FRAME_* deviations, each with a frame number + byte offset (never the record bytes)
|
|
54
|
+
|
|
55
|
+
// Or compose both layers: decode frames → parse the trusted, reassembled records.
|
|
56
|
+
const { message } = parseFramedAstm(framedBytes);
|
|
57
|
+
results(message)[0]?.value; // only checksum-verified frames ever reach the record parser
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>240) frame are each a
|
|
61
|
+
**warning** in the default lenient mode (surfaced, flagged, never silently trusted) and a thrown
|
|
62
|
+
`AstmFrameStrictError` under `{ strict: true }`.
|
|
63
|
+
|
|
64
|
+
## Drive the transport (framed vs raw) + the LTP protocol
|
|
65
|
+
|
|
66
|
+
ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
|
|
67
|
+
vendor** — the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
|
|
68
|
+
cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
|
|
69
|
+
pure protocol reducer with your own socket I/O.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
detectFraming,
|
|
74
|
+
decodeAstmFrames,
|
|
75
|
+
parseAstmRecords,
|
|
76
|
+
ltpInitialState,
|
|
77
|
+
ltpReduce,
|
|
78
|
+
} from "@cosyte/astm";
|
|
79
|
+
|
|
80
|
+
// 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
|
|
81
|
+
const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
|
|
82
|
+
if (framing === "raw") {
|
|
83
|
+
// cobas b121 raw-TCP: no handshake, no frames — parse the record bytes directly.
|
|
84
|
+
parseAstmRecords(rawBytes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Framed transport: drive the pure receiver-side state machine. YOU own the socket + clock.
|
|
88
|
+
let state = ltpInitialState();
|
|
89
|
+
function onControlOrFrame(event) {
|
|
90
|
+
const { state: next, actions, warnings } = ltpReduce(state, event);
|
|
91
|
+
state = next;
|
|
92
|
+
for (const a of actions) {
|
|
93
|
+
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK — only ever for a good frame
|
|
94
|
+
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK — bad checksum ⇒ retransmit
|
|
95
|
+
if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
|
|
96
|
+
}
|
|
97
|
+
void warnings; // ASTM_LTP_* — value-free (a code + at most a frame number)
|
|
98
|
+
}
|
|
99
|
+
// Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
|
|
103
|
+
codec did not vouch for — bad checksum, unterminated, or out of sequence — yields `sendNak`, **never** a
|
|
104
|
+
fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
|
|
105
|
+
The interactive contention/timeout/retransmit **timing** is the consumer's — this layer models the
|
|
106
|
+
state transitions, not the wall-clock timers.
|
|
107
|
+
|
|
108
|
+
## Install
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install @cosyte/astm
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Parse
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { parseAstmRecords, results, patient } from "@cosyte/astm";
|
|
118
|
+
|
|
119
|
+
// A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
|
|
120
|
+
const msg = parseAstmRecords(raw);
|
|
121
|
+
|
|
122
|
+
results(msg)[0]?.value; // the measured value, surfaced raw
|
|
123
|
+
results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
|
|
124
|
+
patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
|
|
125
|
+
msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The parser is **lenient by default** — vendor quirks become warnings, not failures — and refuses to
|
|
129
|
+
produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
|
|
130
|
+
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
131
|
+
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
132
|
+
|
|
133
|
+
## Map local codes to LOINC (LIVD, bring-your-own)
|
|
134
|
+
|
|
135
|
+
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|
|
136
|
+
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message — the
|
|
137
|
+
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
138
|
+
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
|
|
142
|
+
|
|
143
|
+
// Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
|
|
144
|
+
const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
|
|
145
|
+
|
|
146
|
+
const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
|
|
147
|
+
const { annotations, warnings } = applyLivd(msg, catalog);
|
|
148
|
+
|
|
149
|
+
annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
|
|
150
|
+
warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
|
|
154
|
+
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
155
|
+
separately-licensed SNOMED CT — so the package ships no terminology data and you bring the catalog (and
|
|
156
|
+
its license obligations).
|
|
157
|
+
|
|
158
|
+
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
159
|
+
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
160
|
+
> instruments that reuse the same code for different analytes would both match — supply a catalog built
|
|
161
|
+
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
162
|
+
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
163
|
+
|
|
164
|
+
## The cosyte parser archetype
|
|
165
|
+
|
|
166
|
+
- **Postel's Law** — liberal parser (lenient default + warnings), conservative serializer (always
|
|
167
|
+
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
168
|
+
- **Tiered tolerance** — Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
169
|
+
Tier 3 fatal always.
|
|
170
|
+
- **Stable warning codes** — warnings carry stable string codes + positional context; consumers
|
|
171
|
+
branch on `w.code`, so renaming a code is a breaking change.
|
|
172
|
+
- **Zero runtime dependencies** — Node stdlib only (healthcare integrations vet every dependency).
|
|
173
|
+
- **Dual ESM + CJS** — built with `tsup`, validated with `attw`.
|
|
174
|
+
- **Immutability** — parsed models are immutable; mutation is via explicit methods.
|
|
175
|
+
- **Profile system** — a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
176
|
+
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
177
|
+
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
178
|
+
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
179
|
+
- **Terminology recognizer, not a dictionary** — LIVD-aware LOINC recognition is bring-your-own
|
|
180
|
+
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
|
|
181
|
+
LOINC / SNOMED / LIVD data is bundled.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT © Cosyte
|