Spec notes: differential testing against external oracles
test/differential/differential.test.ts parses the canonical corpus
(test/fixtures/canonical/*.hl7, 27 fixtures spanning ADT/ORU/ORM/SIU/MDM/DFT/RDE/RDS/VXU trigger
events, TQ1 timing, Z-segments, and nested-subcomponent edge cases) through both
@cosyte/hl7 and an external, independently-implemented HL7 v2 parser, then compares segment
count and per-segment field extraction. This is a second, independent read of the same corpus.
A real bug that both this repo's own fixtures AND its own implementation happen to agree on
(a shared blind spot) would never surface from @cosyte/hl7's own test suite alone. An external
oracle is the only way to catch that class of bug.
Why this is oracle-gated, not a hard CI dependency
@cosyte/hl7 ships zero runtime dependencies (the standing convention across every
@cosyte/* parser). The oracle is a dev-time-only, external, never-vendored tool: it is
never bundled, published, or required to install the package. If no Python interpreter with the
oracle package importable is available, test/differential/differential.test.ts detects that at
runtime and it.skip()s every comparison. scripts/verify.sh (and CI) stay green with zero
Python present. The harness is a bonus signal when available, not a merge gate on its own
absence.
The oracle: python-hl7
Primary oracle: python-hl7 (PyPI distribution
name hl7: the GitHub repo name and the installable package name differ; pip install hl7, not
pip install python-hl7). Pure-Python, zero C extensions, import hl7. License: BSD
(3-clause), permissive, external-only, safe to invoke as a dev/test tool without any bundling or
distribution obligation back onto @cosyte/hl7.
Optional secondary oracle: hl7apy, MIT-licensed,
more structurally strict (validates against HL7 message-structure definitions rather than
python-hl7's permissive flat-field model). Not wired into the harness today; flagged here as the
natural next oracle if a second, differently-opinionated read becomes valuable. hl7apy's stricter
structural validation would likely surface more "divergences" that are actually hl7apy being
pickier, not @cosyte/hl7 being wrong. Expect to spend real time re-deriving the
known/justified-divergence list documented below if it's ever added.
Licensing bound. Both oracles are permissive (BSD / MIT) and used exclusively as external,
dev-only test tooling, never imported, vendored, or shipped inside the published @cosyte/hl7
package. This is the same bound the umbrella's conventions apply to HAPI/nHapi elsewhere in the
ecosystem: external-only, never bundled.
How to run it
cd hl7 # this repo
python3 -m venv .difftools
.difftools/bin/pip install hl7
pnpm test test/differential/differential.test.ts
.difftools/ is git-ignored (never committed: it's a local dev venv, consistent with the
zero-runtime-dep / never-vendored rule above). The harness also falls back to a plain python3 on
PATH if import hl7 succeeds there, so a system-wide install works too. The venv path is just
the reproducible default.
Detection happens once per run, in test/differential/differential.test.ts's findOraclePython():
try .difftools/bin/python, then python3 on PATH; the first one where
python3 -c "import hl7" exits 0 wins. If neither works, every comparison test calls ctx.skip()
and a one-line console note explains why (pointing back at this doc).
What is compared, and how
For each canonical fixture:
- Segment count.
@cosyte/hl7'smsg.rawSegments.lengthvslen(hl7.parse(raw)). A mismatch here is always a hard failure: it means the two parsers disagree about the message's fundamental shape, never a "known/justified" divergence. - Per-segment name. Segment name at each index must match exactly.
- Per-segment field count, using the SAME 1-indexed HL7 position convention on both sides (see "Field-indexing alignment" below).
- Per-field wire text:
@cosyte/hl7'sField.text(the field's canonical re-serialized wire text: repetitions, components, subcomponents, all re-escaped) against python-hl7'sstr(field)(its reconstruction of the field's original delimiter-joined text).
Field-indexing alignment
Both parsers land on the identical 1-indexed convention by construction, which is what makes a byte-level comparison possible at all rather than needing a translation layer:
- Non-MSH segments: index
0is the segment name (not a data field, excluded from both sides' comparison); indexN >= 1is the HL7 N-th field (PID-5lives at index 5 on both sides). - MSH specifically: index
0/position1is the field separator character itself (MSH-1); index1/position2is the encoding-characters field (MSH-2); indexN >= 2/positionN + 1is MSH-(N+1).@cosyte/hl7's own module JSDoc (src/parser/tokenize.ts) documents this convention explicitly. It was written to match the same "unified 1-indexed, MSH-1 lives at position 1" convention any spec-literate HL7 v2 parser (python-hl7 included) uses. MSH-1/MSH-2 are read literally from the raw tokenizer tree on the@cosyte/hl7side rather than throughField.text, becauseField.text's own JSDoc documents that calling it on MSH-1/MSH-2 re-escapes the delimiter-definition characters into garbage: those two positions aren't "escapable content" the way MSH-3 onward is.
Known / justified divergences
The harness classifies every field-text mismatch against a small, explicit, honestly-derived list
of known-divergence predicates (KNOWN_DIVERGENCES in differential.test.ts). A mismatch that
matches a known predicate is reported, not silently dropped, and does not fail the suite. A
mismatch that matches none of them fails. The point of the allowlist is to keep the
harness a real regression detector, not to paper over an actual bug with an overly broad
tolerance.
D-02 trailing-empty-component canonicalization (observed, on test/fixtures/canonical/tq1-q6h.hl7)
@cosyte/hl7's serializer strips trailing empty components and subcomponents when producing
canonical wire text (Field.text, src/serialize/emit-field.ts: this is D-02, the same rule that
governs msg.toString()'s spec-clean output). python-hl7's str(field) instead returns the
verbatim original substring, trailing empties included.
Concretely: the fixture's TQ1-2 field is 1^tablet^^ (two trailing empty components). hl7
reports "1^tablet"; python-hl7 reports "1^tablet^^". This is Postel's-Law-consistent, intended
behavior on the @cosyte/hl7 side: trailing empty components carry no information the HL7 v2
spec treats as significant, and stripping them is exactly what the serializer (the "be
conservative in what you emit" side of Postel's Law) is supposed to do. python-hl7 makes the
opposite, also-defensible choice: preserve the byte-exact original. Neither is "wrong": they're
different design points on the same lenient-parse/canonical-emit spectrum, and this harness exists
precisely to make that difference visible and documented rather than silently assumed away.
Detection predicate: theirs (the oracle's text) starts with ours (hl7's canonicalized
text) and the remainder is composed entirely of trailing ^/& delimiter characters (i.e., empty
trailing components/subcomponents), a structural, not content-based, check.
Divergences NOT yet observed, but plausible (kept here so a future contributor knows to look)
These are called out because they're common failure modes when differential-testing two
independently-implemented HL7 v2 parsers, but the current 27-fixture canonical corpus doesn't
happen to exercise them. If the corpus grows and one of these starts firing, add its predicate to
KNOWN_DIVERGENCES with the same honesty this file applies to the D-02 case above. Don't broaden
an existing predicate to swallow an unrelated divergence.
- Escape-sequence decoding differences.
@cosyte/hl7's tokenizer decodes escape sequences (unescape()) into the raw tree and the serializer re-escapes on the way back out (reescape()); python-hl7's default parse mode does not auto-decode\F\/\S\/\T\/\R\/\E\. See the empty-oracle-parse workaround this harness'soracle_python_hl7.pymodule JSDoc had to account for while probing python-hl7's behavior (itsstr(field)returns the field as found, escape sequences un-expanded, for anything python-hl7 itself doesn't need to touch). None of the 27 canonical fixtures currently embed a\Xxx\escape inside a field compared by this harness, so this predicted divergence has not fired yet. - Explicit-null (
"") field semantics.@cosyte/hl7distinguishes an empty field (||) from HL7's explicit null token ("") viaRawField.isNull; whether python-hl7'sstr()round-trips""byte-for-byte or normalizes it differently is untested by the current corpus (no canonical fixture uses an explicit null in a field this harness compares). - Lenient-mode recovery on malformed input. The differential harness only runs against the
canonical, spec-clean corpus: every fixture parses successfully on both sides today. It
does not compare lenient-mode recovery behavior (warnings, truncation handling, delimiter
quirks) between the two parsers;
@cosyte/hl7's Postel's-Law tolerance model (test/property/lenient.property.test.ts,test/property/fuzz.property.test.ts) is validated in-repo against its own documented fatal/warning contract instead, since a divergence there would almost certainly reflect the two libraries' deliberately different leniency philosophies rather than a correctness bug in either.
Limitations (read before trusting this harness more than it claims)
- Corpus size. 27 fixtures is a meaningful spread of real-world message shapes, but it is not
exhaustive: the differential harness can only report what the corpus happens to exercise. It
complements, not replaces, the spec-traceability tests and property-based fuzz suite
(
test/property/fuzz.property.test.ts) elsewhere in this repo. - One oracle today. A single external implementation reduces, but does not eliminate, the risk
of a shared blind spot (e.g. both python-hl7 and
@cosyte/hl7independently under-handling the same obscure HL7 v2 corner case). Adding hl7apy as a second oracle would strengthen this further; it is intentionally left as documented future work rather than built speculatively. - Field-text comparison, not full semantic comparison. The harness compares wire text, not
parsed/typed values (e.g. it does not compare
@cosyte/hl7's typedXPN/CX/TScomposites against any equivalent python-hl7 representation, python-hl7 doesn't have typed composites to compare against). This is a deliberate scope boundary: wire-text parity at the segment/field level is what "two parsers agree on how this message is shaped" actually means; composite-type interpretation is@cosyte/hl7-specific value-add tested elsewhere (test/model-types-*.test.ts).