Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,17 @@ Add a changeset for every meaningful change:
pnpm changeset
```

During pre-alpha, pick **patch**: that keeps the package on the `0.0.x` ladder until its first
alpha. See the cosyte version ladder in the meta-repo's `documentation/conventions.md`.
Pick the bump type from what the change does to the published package. On the `0.1.x` ladder:

- **minor** for anything a consumer gains: a newly exported symbol, a new option, a new stable
code, or a new published artifact they can use. It takes `0.1.x` to `0.2.0`.
- **patch** for a fix, for documentation of a surface that already ships, and for contributor-only
tooling. It takes `0.1.0` to `0.1.1`.
- **major** only when you mean to declare `1.0.0`. Before that, a breaking change is a **minor**,
and the break has to be spelled out in the summary, because the number alone tells a consumer on
a `0.x` release nothing.

One exception is enforced rather than chosen: a change to the bytes a seed maps to needs a
**major** changeset, because `pnpm run check:determinism-window` refuses it without one.

See the cosyte version ladder in the meta-repo's `documentation/conventions.md`.
13 changes: 13 additions & 0 deletions .changeset/what-0-1-means.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cosyte/synth": minor
---

`0.1.0` is the first release of `@cosyte/synth` whose public API we ask you to build on.

**What is covered, and what you can depend on.** Seeded generation of spec-clean test fixtures in six formats, each built through that format's own `@cosyte/*` builder or serializer and read back by its parser with zero warnings: HL7 v2 (ADT, ORU, ORM, SIU and VXU), FHIR R4 and US Core (the clinical resources, fixtures requested by US Core 6.1.0 profile name, and `collection`, `transaction` and `document` Bundles), C-CDA R2.1 (CCD and Referral Note), X12 005010 (837P, 837I, 837D, 835 and 271), NCPDP SCRIPT (NewRx, RxRenewalRequest and RxChangeRequest) and Telecom (B1, B2 and B3), and ASTM E1394 records with E1381 framing. Every identifier, name, date, phone and address comes from a reserved range or the shipped fake-name pool, never from real data. Also covered: vendor-quirk fixtures for HL7 v2, C-CDA and ASTM, each round-tripping to exactly one intended warning; the pairing loop with `@cosyte/deid`; the `safe` value providers and their validators; and the stable `SYNTH_*` fatal codes. Node.js 22 and 24, ESM and CommonJS, with type declarations for both, and no third-party runtime dependency.

**What the version promises.** The same seed yields byte-identical output, verified across Node 22 and Node 24 on every change, and a change to what a seed maps to ships only in a release that declares a major version, so a golden fixture pinned to a `0.x` version keeps matching. For the rest of the API: until 1.0, a breaking change raises the minor version (0.1 to 0.2), and the changelog entry says what broke and what to change. A patch release (0.1.x) does not break you, so a `^0.1.0` range takes the patches and stops before 0.2.0.

**What is not covered yet.** Quirk recipes for FHIR, X12 and NCPDP; the X12 270 eligibility request and the NCPDP SCRIPT lifecycle responses, which wait on builders in the parsers; `@cosyte/deid` pairing for NCPDP SCRIPT, ASTM and DICOM; DICOM generation; and clinical realism, since this is a format and conformance generator rather than a clinical simulator. The pairing loop co-validates the two packages on this package's own output and is not an independent audit of `@cosyte/deid`. The documentation at https://docs.cosyte.com/synth lists the known limitations in full.

The repository now carries runnable examples under `examples/`, run against the built package on every change.
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,36 @@ jobs:

- name: Check the compatibility window
run: pnpm run check:determinism-window

# ── THE EXAMPLES ─────────────────────────────────────────────────────────────────────────────
#
# The programs under examples/, run against the BUILT package the way a consumer imports it:
# `pnpm examples` (scripts/run-examples.ts) fails when any example exits non-zero, and each
# example checks its own output. Added on top of the caller above, never instead of it. Not one
# of the determinism jobs, so the reconciliation in scripts/determinism/engines.ts does not read
# it. `examples (22)` and `examples (24)` are new contexts and deliberately NOT required yet: let
# them run on `main` first, then read the real names off a live check run before requiring them.
examples:
strategy:
fail-fast: false
matrix:
node: ["22", "24"]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node }}
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Build (the examples import the built package)
run: pnpm build

- name: Run every example
run: pnpm examples
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ date, phone, and address from a **guaranteed-non-colliding synthetic source**. I
**format/conformance generator, not a clinical simulator**: it does not model disease progression
(that is Synthea).

> **Status:** pre-alpha (`0.0.x`), published to npm. The version shown on the npm package page is the
> one that is live; this page never repeats it. The generator is **feature-complete**: the
> **Status:** `0.1`, published to npm. The version shown on the npm package page is the one that is
> live; this page never repeats it. Below 1.0, a breaking change raises the minor version and the
> changelog says what broke. The generator is **feature-complete**: the
> seeded-PRNG core, the synthetic-safety providers, and the round-trip harness; **spec-clean generation
> across all six formats**, HL7 v2 (`ADT`/`ORU`/`ORM`/`SIU`/`VXU`), FHIR R4 / US Core (the full clinical
> set + `collection`/`transaction`/`document` Bundles), C-CDA R2.1 (CCD + Referral Note), X12 005010
Expand Down Expand Up @@ -61,15 +62,15 @@ const adt = generateAdt({ seed: 12345, trigger: "A01" });
const oru = generateOru({ seed: 12345 });

// Spec-clean by construction: it round-trips through @cosyte/hl7 with zero warnings.
roundTrip(adt).specClean; // true
roundTrip(oru).specClean; // true
console.log(roundTrip(adt).specClean); // true
console.log(roundTrip(oru).specClean); // true

// Or generate a reproducible mixed corpus across every family:
const corpus = hl7Corpus({ seed: 42, count: 7 }); // one of each family, cycled
corpus.artifacts.every((a) => a.warnings.length === 0); // true, all spec-clean
console.log(corpus.artifacts.every((a) => a.warnings.length === 0)); // true, all spec-clean

// Dispatch by kind when the message type is data:
generateHl7("VXU^V04", 12345);
console.log(roundTrip(generateHl7("VXU^V04", 12345)).content.split("\r")[0]); // its MSH segment
```

## Generate a spec-clean FHIR R4 / US Core resource
Expand Down
4 changes: 2 additions & 2 deletions docs-content/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ sidebar_position: 1
its core. It ships dual **ESM + CJS** builds with per-condition type declarations, so it works from
either module system without configuration.

> **Status:** pre-alpha (`0.0.x`), published to npm. The version shown on the npm package page is the
> one that is live; this page never repeats it.
> **Status:** `0.1`, published to npm. The version shown on the npm package page is the one that is
> live; this page never repeats it.

## Prerequisites

Expand Down
4 changes: 2 additions & 2 deletions docs-content/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ never-issued SSNs, NANP `555-01xx` phones, `example.*` domains, TEST-NET IPs, a
authority for MRNs, and a shipped clearly-fake name pool). It is a **format/conformance generator, not a
clinical simulator**: it does not model disease progression (that is Synthea).

> **Status:** pre-alpha (`0.0.x`), published to npm. The version shown on the npm package page is the
> one that is live; this page never repeats it.
> **Status:** `0.1`, published to npm. The version shown on the npm package page is the one that is
> live; this page never repeats it.

## Install

Expand Down
12 changes: 11 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ import cosyte from "@cosyte/eslint-config";
* `Math.random` freely.
*/
export default [
...cosyte(import.meta.dirname),
...cosyte(import.meta.dirname, {
files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "examples/**/*.ts", "*.config.ts"],
}),

// The examples are programs a reader runs: printing what they show is their job.
{
files: ["examples/**/*.ts"],
rules: {
"no-console": "off",
},
},

{
files: ["src/**/*.ts"],
Expand Down
31 changes: 31 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Examples

Four small programs that show what `@cosyte/synth` does, each runnable as it stands. Every example
imports `@cosyte/synth` by its published name, which resolves through this package's own `exports`
to the built `dist/`, so build first. Each one checks its own output and exits non-zero on a
mismatch.

```bash
pnpm install
pnpm build
pnpm examples # runs all four
pnpm tsx examples/generate-an-hl7-message.ts # or one at a time
```

| Example | What it shows |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`generate-an-hl7-message.ts`](./generate-an-hl7-message.ts) | An ADT^A01 from a seed: spec-clean on re-parse, byte-identical for the same seed, and every identifier from a reserved range (synthetic assigning authority, `555-01xx` phone, never-issued SSN). |
| [`generate-every-format.ts`](./generate-every-format.ts) | One artifact each for FHIR US Core (a Patient and a transaction Bundle), C-CDA, X12 837P, NCPDP SCRIPT and Telecom, and ASTM, each round-tripped through its own parser with zero warnings. |
| [`vendor-quirk.ts`](./vendor-quirk.ts) | Off-spec fixtures for HL7 v2, C-CDA and ASTM, each producing exactly the one warning it was built to cause, and tolerated by the public parser profile that claims the deviation. |
| [`deid-pairing-loop.ts`](./deid-pairing-loop.ts) | The co-validation loop with `@cosyte/deid`: planted synthetic sentinels all removed, no clinical value over-scrubbed, and the formats with no de-identification adapter skipped and named. |

Nothing here is real patient data, by construction: every value comes from a reserved range or the
shipped fake-name pool. The pairing loop is a co-validation of the two packages on this package's
own output, not an independent audit of `@cosyte/deid`.

The format parsers and `@cosyte/deid` are optional peer dependencies of the package and development
dependencies here. In your project, install only the ones whose fixtures you generate.

`tsconfig.json` in this folder maps `@cosyte/synth` to the source, so `pnpm typecheck` and
`pnpm lint` check the examples before anything is built. At run time nothing maps the name: Node
resolves it through `exports`, as it does in your project.
50 changes: 50 additions & 0 deletions examples/deid-pairing-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Co-validate `@cosyte/synth` with `@cosyte/deid`: generate, plant synthetic PHI sentinels,
* de-identify, and check that every sentinel is gone while the clinical payload survives.
*
* This is a co-validation harness on synth's own output, not an independent audit of
* `@cosyte/deid` against real-world data. The removal check is scoped to the former PHI positions,
* so provider identity a de-identifier legitimately keeps never reads as a false survivor. Formats
* with no `@cosyte/deid` adapter are skipped and named, never counted as passes.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/deid-pairing-loop.ts
*/

import assert from "node:assert/strict";

import {
ccdaDeidLoop,
fhirDeidLoop,
hl7DeidLoop,
ncpdpTelecomDeidLoop,
summarizeDeidCoverage,
x12DeidLoop,
} from "@cosyte/synth/deid";

const oru = hl7DeidLoop({ seed: 42, kind: "ORU^R01" });
console.log(`HL7 ORU^R01: planted at ${oru.planted.map((sentinel) => sentinel.locus).join(", ")}`);
assert.equal(oru.pass, true);
assert.deepEqual(oru.survivors, [], "every planted sentinel was removed");
assert.deepEqual(oru.clinicalScrubbed, [], "no clinical value was over-scrubbed");

const summary = summarizeDeidCoverage([
hl7DeidLoop({ seed: 1 }),
fhirDeidLoop({ seed: 1 }),
x12DeidLoop({ seed: 1, variant: "837P" }),
ncpdpTelecomDeidLoop({ seed: 1, transaction: "B1" }),
ccdaDeidLoop({ seed: 1 }),
]);

for (const row of summary.byFormat) {
console.log(
`${row.format.padEnd(14)} planted ${String(row.planted).padStart(2)}, survivors ${String(row.survivors)}, clinical scrubbed ${String(row.clinicalScrubbed)}`,
);
}
console.log(`skipped: ${summary.skipped.map((entry) => entry.format).join(", ")}`);

assert.equal(summary.allPass, true);
assert.equal(summary.totalSurvivors, 0);
assert.equal(summary.totalClinicalScrubbed, 0);
assert.ok(summary.totalPlanted > 0, "the loop planted something to find");
56 changes: 56 additions & 0 deletions examples/generate-an-hl7-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Generate a spec-clean HL7 v2 ADT^A01 from a seed, prove the seed alone decides the bytes, and check
* that every identifier in it is synthetic by construction.
*
* The message is built through `@cosyte/hl7`'s own builder and read straight back by its parser: a
* spec-clean artifact re-parses with zero warnings. `@cosyte/hl7` is an optional peer dependency:
* install it beside `@cosyte/synth` to use this subpath.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/generate-an-hl7-message.ts
*/

import assert from "node:assert/strict";

import { isItinFormatted, isSyntheticPhone, isSyntheticSsn } from "@cosyte/synth";
import { generateAdt, hl7Corpus, roundTrip } from "@cosyte/synth/hl7";

const message = roundTrip(generateAdt({ seed: 12345, trigger: "A01" }));
console.log(message.content.split("\r").join("\n"));

// Spec-clean by construction: zero warnings on re-parse, and the re-serialized bytes are identical.
assert.equal(message.specClean, true);
assert.deepEqual(message.warnings, []);
assert.equal(message.byteStable, true);

// The seed, and only the seed, decides the output: same seed, same bytes, on any machine.
const again = roundTrip(generateAdt({ seed: 12345, trigger: "A01" }));
assert.equal(again.content, message.content, "the same seed yields byte-identical output");
const other = roundTrip(generateAdt({ seed: 12346, trigger: "A01" }));
assert.notEqual(other.content, message.content, "a different seed yields a different message");
console.log("seed 12345 twice: byte-identical");

// Every identifier is drawn from a reserved range or a shipped fake-name pool.
const pid = message.content
.split("\r")
.find((segment) => segment.startsWith("PID|"))
?.split("|");
const mrnAuthority = pid?.[3]?.split("^")[3];
const phone = pid?.[13] ?? "";
const ssn = pid?.[19] ?? "";
console.log(`PID-3 assigning authority: ${String(mrnAuthority)}`);
console.log(`PID-13 phone ${phone}: synthetic ${String(isSyntheticPhone(phone))}`);
console.log(
`PID-19 SSN ${ssn}: never issued ${String(isSyntheticSsn(ssn))}, ITIN-shaped ${String(isItinFormatted(ssn))}`,
);
assert.equal(mrnAuthority, "COSYTE-SYNTH", "the MRN lives under the synthetic assigning authority");
assert.equal(isSyntheticPhone(phone), true, "the phone is a reserved 555-01xx number");
assert.equal(isSyntheticSsn(ssn), true, "the SSN is one the SSA never issues");
assert.equal(isItinFormatted(ssn), false, "and it is not a validly formatted IRS ITIN either");

// A reproducible mixed corpus: one of each message family, all spec-clean.
const corpus = hl7Corpus({ seed: 42, count: 7 });
console.log(`corpus: ${Object.keys(corpus.manifest.counts).join(", ")}`);
assert.equal(corpus.artifacts.length, 7);
assert.ok(corpus.artifacts.every((artifact) => artifact.warnings.length === 0));
55 changes: 55 additions & 0 deletions examples/generate-every-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Generate one spec-clean artifact in each of the other five formats, from a seed, and round-trip
* each through its own parser.
*
* FHIR R4 / US Core, C-CDA R2.1, X12 005010, NCPDP (SCRIPT and Telecom) and ASTM: each is built
* through that format's own `@cosyte/*` builder or serializer, never hand-written bytes, and each
* re-parses with zero warnings. Every parser is an optional peer dependency: install only the ones
* whose fixtures you generate.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/generate-every-format.ts
*/

import assert from "node:assert/strict";

import { astmRoundTrip, generateAstmResult } from "@cosyte/synth/astm";
import { generateCcd, roundTrip as ccdaRoundTrip } from "@cosyte/synth/ccda";
import { generateBundle, generatePatient, roundTrip as fhirRoundTrip } from "@cosyte/synth/fhir";
import { generateB1, generateNewRx, scriptRoundTrip, telecomRoundTrip } from "@cosyte/synth/ncpdp";
import { generate837P, roundTrip as x12RoundTrip } from "@cosyte/synth/x12";

interface RoundTripResult {
readonly content: string;
readonly warnings: readonly string[];
readonly specClean: boolean;
}

/** Generate twice from one seed, require identical bytes and a spec-clean round trip, and say so. */
function check(label: string, generate: (seed: number) => RoundTripResult): void {
const first = generate(12345);
const second = generate(12345);
console.log(
`${label.padEnd(26)} ${String(first.content.length).padStart(6)} bytes, spec-clean ${String(first.specClean)}, warnings ${String(first.warnings.length)}`,
);
assert.equal(first.specClean, true, `${label} is spec-clean`);
assert.deepEqual(first.warnings, [], `${label} re-parses with zero warnings`);
assert.equal(
second.content,
first.content,
`${label}: the same seed yields byte-identical output`,
);
}

check("FHIR US Core Patient", (seed) =>
fhirRoundTrip(generatePatient({ seed, profile: "us-core" })),
);
check("FHIR transaction Bundle", (seed) =>
fhirRoundTrip(generateBundle({ seed, type: "transaction" })),
);
check("C-CDA CCD", (seed) => ccdaRoundTrip(generateCcd({ seed })));
check("X12 837P claim", (seed) => x12RoundTrip(generate837P({ seed })));
check("NCPDP SCRIPT NewRx", (seed) => scriptRoundTrip(generateNewRx({ seed })));
check("NCPDP Telecom B1", (seed) => telecomRoundTrip(generateB1({ seed })));
check("ASTM result report", (seed) => astmRoundTrip(generateAstmResult({ seed })));
16 changes: 16 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@cosyte/synth": ["../src/index.ts"],
"@cosyte/synth/hl7": ["../src/hl7/index.ts"],
"@cosyte/synth/fhir": ["../src/fhir/index.ts"],
"@cosyte/synth/ccda": ["../src/ccda/index.ts"],
"@cosyte/synth/x12": ["../src/x12/index.ts"],
"@cosyte/synth/ncpdp": ["../src/ncpdp/index.ts"],
"@cosyte/synth/astm": ["../src/astm/index.ts"],
"@cosyte/synth/deid": ["../src/deid/index.ts"]
}
},
"include": ["*.ts"]
}
Loading
Loading