dicom-synth 1.4.0 → 1.5.0

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
@@ -381,6 +381,42 @@ The validator throws with a human-readable message on any structural error.
381
381
 
382
382
  ---
383
383
 
384
+ ## Parametric designer
385
+
386
+ A `ParametricSpec` describes a dataset by ranges instead of explicit entries. `resolveParametricSpec` resolves it — deterministically for a given seed — into a concrete `DatasetSpec` that can be inspected, saved, and re-run.
387
+
388
+ ```ts
389
+ import { resolveParametricSpec, writeCollectionFromSpec } from 'dicom-synth'
390
+
391
+ const resolved = resolveParametricSpec({
392
+ seed: 42,
393
+ studies: {
394
+ count: { min: 2, max: 4 }, // ranges are inclusive
395
+ seriesPerStudy: { min: 1, max: 3 },
396
+ filesPerSeries: { min: 5, max: 20 },
397
+ fileSizeKb: { min: 100, max: 600 }, // sampled per file
398
+ modalities: ['CT', 'PT'], // one drawn per series
399
+ },
400
+ edgeCases: [{ type: 'invalid-uid-image', frequency: 0.05 }],
401
+ layout: 'hierarchical',
402
+ })
403
+ await writeCollectionFromSpec(resolved, './out')
404
+ ```
405
+
406
+ - Every field of `studies` accepts a fixed number or an inclusive `{ min, max }` range (`Range`).
407
+ - `fileSizeKb` maps to `targetSizeKb`; a fixed value collapses each series into one counted entry, a range samples per file.
408
+ - `edgeCases` are `EntrySpec`s appended to the flat `entries` of the resolved spec. With `frequency` (0–1), the count is drawn per file across the whole dataset — the resolved spec records the actual draw. Without it, the entry is appended as-is (use `count` for absolute numbers). At small dataset sizes a low frequency may draw zero occurrences.
409
+ - The seed (drawn randomly when omitted) is embedded in the resolved spec, so resolution is always reproducible.
410
+
411
+ ```ts
412
+ import type { ParametricSpec, ParametricStudies, ParametricEdgeCase, Range } from 'dicom-synth'
413
+ import { validateParametricSpec } from 'dicom-synth'
414
+ ```
415
+
416
+ See `examples/parametric.json` for a JSON example and `examples/convert-data-shape.mjs` for converting an exported tree-shape JSON into a `DatasetSpec` (skips non-DICOM files).
417
+
418
+ ---
419
+
384
420
  ## CLI
385
421
 
386
422
  ```bash
@@ -392,8 +428,21 @@ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":
392
428
 
393
429
  # Default output directory is ./fixtures/generated
394
430
  dicom-synth-generate --schema dataset.json
431
+
432
+ # Resolve and generate from a parametric spec; save the resolved DatasetSpec
433
+ dicom-synth-generate --parametric examples/parametric.json --out ./out --emit-spec resolved.json
434
+
435
+ # Preview the manifest (paths, types, sizes) without writing anything
436
+ dicom-synth-generate --parametric examples/parametric.json --dry-run
395
437
  ```
396
438
 
439
+ | Flag | Effect |
440
+ |---|---|
441
+ | `--schema <path>` / `--schema-inline <json>` / `--parametric <path>` | Input spec (mutually exclusive) |
442
+ | `--out <dir>` | Output directory (default `./fixtures/generated`) |
443
+ | `--emit-spec <path>` | Write the resolved `DatasetSpec` JSON (requires `--parametric`) |
444
+ | `--dry-run` | Generate in memory and print the manifest; nothing is written |
445
+
397
446
  The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
398
447
 
399
448
  ```bash
@@ -471,7 +520,8 @@ pnpm fetch:public-case -- pydicom-CT-small
471
520
  | Path | Responsibility |
472
521
  |---|---|
473
522
  | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
474
- | `src/schema/validate.ts` | `validateDatasetSpec` — validates a raw JSON value against the schema |
523
+ | `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` validate raw JSON values against the schema |
524
+ | `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
475
525
  | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
476
526
  | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
477
527
  | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
@@ -481,6 +531,8 @@ pnpm fetch:public-case -- pydicom-CT-small
481
531
  | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
482
532
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
483
533
  | `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
534
+ | `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
535
+ | `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
484
536
 
485
537
  ---
486
538
 
@@ -506,9 +558,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
506
558
 
507
559
  ## Future development
508
560
 
509
- - **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
510
- - **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
511
- - **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
561
+ - **Describe tool** — scan an existing DICOM tree and emit a matching `DatasetSpec` (counts, grouping, sizes, modalities; no PHI)
562
+ - **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
512
563
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
513
564
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
514
565
 
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs'
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import {
5
+ generateCollectionFromSpec,
6
+ resolveParametricSpec,
5
7
  validateDatasetSpec,
6
8
  writeCollectionFromSpec,
7
9
  } from '../dist/esm/index.js'
8
10
 
9
11
  function usage() {
10
12
  console.error(
11
- 'Usage: dicom-synth-generate --schema <path> [--out <dir>]\n' +
12
- ' dicom-synth-generate --schema-inline <json> [--out <dir>]',
13
+ 'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--dry-run]\n' +
14
+ ' dicom-synth-generate --schema-inline <json> [--out <dir>] [--dry-run]\n' +
15
+ ' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]',
13
16
  )
14
17
  process.exit(1)
15
18
  }
@@ -19,36 +22,50 @@ const args = process.argv.slice(2)
19
22
  if (args.length === 0) usage()
20
23
 
21
24
  let rawSpec
25
+ let specSource
22
26
  let outDir = resolve('./fixtures/generated')
27
+ let emitSpecPath
28
+ let dryRun = false
29
+
30
+ function readSpecArg(flag, value) {
31
+ if (rawSpec !== undefined) {
32
+ console.error(
33
+ 'Error: --schema, --schema-inline and --parametric are mutually exclusive',
34
+ )
35
+ process.exit(1)
36
+ }
37
+ specSource = flag
38
+ if (flag === '--schema-inline') {
39
+ try {
40
+ rawSpec = JSON.parse(value)
41
+ } catch (err) {
42
+ console.error(`Error parsing inline schema: ${err.message}`)
43
+ process.exit(1)
44
+ }
45
+ } else {
46
+ try {
47
+ rawSpec = JSON.parse(readFileSync(resolve(value), 'utf8'))
48
+ } catch (err) {
49
+ console.error(`Error reading ${flag.slice(2)} file: ${err.message}`)
50
+ process.exit(1)
51
+ }
52
+ }
53
+ }
23
54
 
24
55
  for (let i = 0; i < args.length; i++) {
25
56
  if (
26
- (args[i] === '--schema' || args[i] === '--schema-inline') &&
57
+ (args[i] === '--schema' ||
58
+ args[i] === '--schema-inline' ||
59
+ args[i] === '--parametric') &&
27
60
  args[i + 1]
28
61
  ) {
29
- if (rawSpec !== undefined) {
30
- console.error(
31
- 'Error: --schema and --schema-inline are mutually exclusive',
32
- )
33
- process.exit(1)
34
- }
35
- if (args[i] === '--schema') {
36
- try {
37
- rawSpec = JSON.parse(readFileSync(resolve(args[++i]), 'utf8'))
38
- } catch (err) {
39
- console.error(`Error reading schema file: ${err.message}`)
40
- process.exit(1)
41
- }
42
- } else {
43
- try {
44
- rawSpec = JSON.parse(args[++i])
45
- } catch (err) {
46
- console.error(`Error parsing inline schema: ${err.message}`)
47
- process.exit(1)
48
- }
49
- }
62
+ readSpecArg(args[i], args[++i])
50
63
  } else if (args[i] === '--out' && args[i + 1]) {
51
64
  outDir = resolve(args[++i])
65
+ } else if (args[i] === '--emit-spec' && args[i + 1]) {
66
+ emitSpecPath = resolve(args[++i])
67
+ } else if (args[i] === '--dry-run') {
68
+ dryRun = true
52
69
  } else {
53
70
  console.error(`Unknown argument: ${args[i]}`)
54
71
  usage()
@@ -57,29 +74,72 @@ for (let i = 0; i < args.length; i++) {
57
74
 
58
75
  if (rawSpec === undefined) usage()
59
76
 
60
- let spec
61
- try {
62
- spec = validateDatasetSpec(rawSpec)
63
- } catch (err) {
64
- console.error(`Schema validation error: ${err.message}`)
77
+ function printTypeCounts(counts) {
78
+ for (const [type, count] of Object.entries(counts)) {
79
+ console.log(` ${type}: ${count}`)
80
+ }
81
+ }
82
+
83
+ if (emitSpecPath !== undefined && specSource !== '--parametric') {
84
+ console.error('Error: --emit-spec requires --parametric')
65
85
  process.exit(1)
66
86
  }
67
87
 
68
- let manifest
88
+ let spec
69
89
  try {
70
- manifest = await writeCollectionFromSpec(spec, outDir)
90
+ spec =
91
+ specSource === '--parametric'
92
+ ? resolveParametricSpec(rawSpec)
93
+ : validateDatasetSpec(rawSpec)
71
94
  } catch (err) {
72
- console.error(`Error writing collection: ${err.message}`)
95
+ console.error(`Schema validation error: ${err.message}`)
73
96
  process.exit(1)
74
97
  }
75
98
 
76
- // Summary by type
77
- const counts = {}
78
- for (const entry of manifest) {
79
- counts[entry.type] = (counts[entry.type] ?? 0) + 1
99
+ if (emitSpecPath !== undefined) {
100
+ try {
101
+ writeFileSync(emitSpecPath, `${JSON.stringify(spec, null, 2)}\n`)
102
+ console.log(`Wrote resolved spec to ${emitSpecPath}`)
103
+ } catch (err) {
104
+ console.error(`Error writing resolved spec: ${err.message}`)
105
+ process.exit(1)
106
+ }
80
107
  }
81
108
 
82
- console.log(`Wrote ${manifest.length} file(s) to ${outDir}`)
83
- for (const [type, count] of Object.entries(counts)) {
84
- console.log(` ${type}: ${count}`)
109
+ if (dryRun) {
110
+ const counts = {}
111
+ let fileCount = 0
112
+ let byteCount = 0
113
+ try {
114
+ for await (const file of generateCollectionFromSpec(spec)) {
115
+ console.log(
116
+ `${file.relativePath} (${file.type}, ${(file.buffer.length / 1024).toFixed(1)} KB)`,
117
+ )
118
+ counts[file.type] = (counts[file.type] ?? 0) + 1
119
+ fileCount++
120
+ byteCount += file.buffer.length
121
+ }
122
+ } catch (err) {
123
+ console.error(`Error generating collection: ${err.message}`)
124
+ process.exit(1)
125
+ }
126
+ console.log(
127
+ `Dry run: ${fileCount} file(s), ${(byteCount / 1024).toFixed(1)} KB total — nothing written`,
128
+ )
129
+ printTypeCounts(counts)
130
+ } else {
131
+ let manifest
132
+ try {
133
+ manifest = await writeCollectionFromSpec(spec, outDir)
134
+ } catch (err) {
135
+ console.error(`Error writing collection: ${err.message}`)
136
+ process.exit(1)
137
+ }
138
+
139
+ const counts = {}
140
+ for (const entry of manifest) {
141
+ counts[entry.type] = (counts[entry.type] ?? 0) + 1
142
+ }
143
+ console.log(`Wrote ${manifest.length} file(s) to ${outDir}`)
144
+ printTypeCounts(counts)
85
145
  }
package/dist/esm/index.js CHANGED
@@ -85,6 +85,25 @@ function validatePositiveInt(value, path) {
85
85
  );
86
86
  }
87
87
  }
88
+ function validateSeed(value, path) {
89
+ if (typeof value !== "number" || !Number.isFinite(value)) {
90
+ throw new Error(
91
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
92
+ );
93
+ }
94
+ }
95
+ function validateSizeKbLimit(kb, path) {
96
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
97
+ throw new Error(`${path}: exceeds the 512 MB limit`);
98
+ }
99
+ }
100
+ function validateEnum(value, valid, path) {
101
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
102
+ throw new Error(
103
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
104
+ );
105
+ }
106
+ }
88
107
  function validateTagKey(key, path) {
89
108
  if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
90
109
  throw new Error(
@@ -97,11 +116,7 @@ function validateEntry(entry, path) {
97
116
  throw new Error(`${path}: must be an object`);
98
117
  }
99
118
  const e = entry;
100
- if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
101
- throw new Error(
102
- `${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
103
- );
104
- }
119
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
105
120
  const type = e.type;
106
121
  const caps = TYPE_CAPABILITIES[type];
107
122
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
@@ -119,18 +134,14 @@ function validateEntry(entry, path) {
119
134
  throw new Error(`${path}.${field}: not supported for type "${type}"`);
120
135
  }
121
136
  if ("transferSyntax" in e) {
122
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
123
- throw new Error(
124
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
125
- );
126
- }
137
+ validateEnum(
138
+ e.transferSyntax,
139
+ VALID_TRANSFER_SYNTAXES,
140
+ `${path}.transferSyntax`
141
+ );
127
142
  }
128
143
  if ("modality" in e) {
129
- if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
130
- throw new Error(
131
- `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
132
- );
133
- }
144
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
134
145
  }
135
146
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
136
147
  if ("violations" in e) {
@@ -138,11 +149,7 @@ function validateEntry(entry, path) {
138
149
  throw new Error(`${path}.violations: must be an array`);
139
150
  }
140
151
  for (const [i, v] of e.violations.entries()) {
141
- if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
142
- throw new Error(
143
- `${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
144
- );
145
- }
152
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
146
153
  }
147
154
  }
148
155
  const hasDims = "rows" in e || "columns" in e || "frames" in e;
@@ -153,9 +160,7 @@ function validateEntry(entry, path) {
153
160
  );
154
161
  }
155
162
  validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
156
- if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
157
- throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
158
- }
163
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
159
164
  } else if (hasDims) {
160
165
  if (!("rows" in e) || !("columns" in e)) {
161
166
  throw new Error(`${path}: rows and columns must be provided together`);
@@ -240,23 +245,104 @@ function validateDatasetSpec(raw) {
240
245
  const studies = rawStudies.map(
241
246
  (study, i) => validateStudy(study, `studies[${i}]`)
242
247
  );
243
- if ("seed" in r) {
244
- if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
248
+ if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
249
+ if ("layout" in r) {
250
+ validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
251
+ }
252
+ return {
253
+ ...entries.length > 0 ? { entries } : {},
254
+ ...studies.length > 0 ? { studies } : {},
255
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
256
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
257
+ };
258
+ }
259
+ function validateRange(value, path) {
260
+ if (typeof value === "number") {
261
+ validatePositiveInt(value, path);
262
+ return value;
263
+ }
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
265
+ throw new Error(
266
+ `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
267
+ );
268
+ }
269
+ const r = value;
270
+ validatePositiveInt(r.min, `${path}.min`);
271
+ validatePositiveInt(r.max, `${path}.max`);
272
+ if (r.min > r.max) {
273
+ throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
274
+ }
275
+ return value;
276
+ }
277
+ function rangeMax(range) {
278
+ return typeof range === "number" ? range : range.max;
279
+ }
280
+ function validateParametricStudies(studies, path) {
281
+ if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
282
+ throw new Error(`${path}: must be an object`);
283
+ }
284
+ const s = studies;
285
+ for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
286
+ if (!(field in s)) throw new Error(`${path}.${field}: is required`);
287
+ validateRange(s[field], `${path}.${field}`);
288
+ }
289
+ if ("fileSizeKb" in s) {
290
+ validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
291
+ validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
292
+ }
293
+ if ("modalities" in s) {
294
+ if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
295
+ throw new Error(`${path}.modalities: must be a non-empty array`);
296
+ }
297
+ for (const [i, m] of s.modalities.entries()) {
298
+ validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
299
+ }
300
+ }
301
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
302
+ return s;
303
+ }
304
+ function validateEdgeCase(edgeCase, path) {
305
+ const e = validateEntry(edgeCase, path);
306
+ if ("frequency" in e) {
307
+ if ("count" in e) {
245
308
  throw new Error(
246
- `DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
309
+ `${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
247
310
  );
248
311
  }
249
- }
250
- if ("layout" in r) {
251
- if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
312
+ const f = e.frequency;
313
+ if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
252
314
  throw new Error(
253
- `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
315
+ `${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
254
316
  );
255
317
  }
256
318
  }
319
+ return e;
320
+ }
321
+ function validateParametricSpec(raw) {
322
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
323
+ throw new Error("ParametricSpec: must be a JSON object");
324
+ }
325
+ const r = raw;
326
+ if (!("studies" in r)) {
327
+ throw new Error("ParametricSpec.studies: is required");
328
+ }
329
+ const studies = validateParametricStudies(r.studies, "studies");
330
+ const edgeCases = [];
331
+ if ("edgeCases" in r) {
332
+ if (!Array.isArray(r.edgeCases)) {
333
+ throw new Error("ParametricSpec.edgeCases: must be an array");
334
+ }
335
+ for (const [i, e] of r.edgeCases.entries()) {
336
+ edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
337
+ }
338
+ }
339
+ if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
340
+ if ("layout" in r) {
341
+ validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
342
+ }
257
343
  return {
258
- ...entries.length > 0 ? { entries } : {},
259
- ...studies.length > 0 ? { studies } : {},
344
+ studies,
345
+ ...edgeCases.length > 0 ? { edgeCases } : {},
260
346
  ...r.seed !== void 0 ? { seed: r.seed } : {},
261
347
  ...r.layout !== void 0 ? { layout: r.layout } : {}
262
348
  };
@@ -457,6 +543,7 @@ function seededUid(next, role) {
457
543
  return formatUid(next(), next(), role);
458
544
  }
459
545
  var GROUP_STREAM_OFFSET = 2654435769;
546
+ var PARAMETRIC_STREAM_OFFSET = 2246822507;
460
547
  function seededStream(seed, offset, a, b) {
461
548
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
462
549
  }
@@ -793,6 +880,80 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
793
880
  writeFileSync2(dest, buf);
794
881
  return dest;
795
882
  }
883
+
884
+ // src/schema/parametric.ts
885
+ import { randomBytes as randomBytes2 } from "node:crypto";
886
+ function sample(next, range) {
887
+ if (typeof range === "number") return range;
888
+ return range.min + next() % (range.max - range.min + 1);
889
+ }
890
+ function pick(next, items) {
891
+ return items[next() % items.length];
892
+ }
893
+ function fraction(next) {
894
+ return next() / 4294967296;
895
+ }
896
+ function resolveParametricSpec(spec) {
897
+ const validated = validateParametricSpec(spec);
898
+ const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
899
+ const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
900
+ const params = validated.studies;
901
+ const studies = [];
902
+ let totalFiles = 0;
903
+ const studyCount = sample(next, params.count);
904
+ for (let st = 0; st < studyCount; st++) {
905
+ const seriesCount = sample(next, params.seriesPerStudy);
906
+ const series = [];
907
+ for (let se = 0; se < seriesCount; se++) {
908
+ const fileCount = sample(next, params.filesPerSeries);
909
+ totalFiles += fileCount;
910
+ const modality = params.modalities ? pick(next, params.modalities) : void 0;
911
+ const base = {
912
+ type: "valid-image",
913
+ ...modality !== void 0 ? { modality } : {}
914
+ };
915
+ const entries2 = [];
916
+ if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
917
+ for (let f = 0; f < fileCount; f++) {
918
+ entries2.push({
919
+ ...base,
920
+ targetSizeKb: sample(next, params.fileSizeKb)
921
+ });
922
+ }
923
+ } else {
924
+ entries2.push({
925
+ ...base,
926
+ ...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
927
+ ...fileCount > 1 ? { count: fileCount } : {}
928
+ });
929
+ }
930
+ series.push({ entries: entries2 });
931
+ }
932
+ studies.push({
933
+ series,
934
+ ...params.tags !== void 0 ? { tags: params.tags } : {}
935
+ });
936
+ }
937
+ const entries = [];
938
+ for (const edgeCase of validated.edgeCases ?? []) {
939
+ const { frequency, ...entry } = edgeCase;
940
+ if (frequency === void 0) {
941
+ entries.push(entry);
942
+ continue;
943
+ }
944
+ let drawn = 0;
945
+ for (let i = 0; i < totalFiles; i++) {
946
+ if (fraction(next) < frequency) drawn++;
947
+ }
948
+ if (drawn > 0) entries.push({ ...entry, count: drawn });
949
+ }
950
+ return {
951
+ ...entries.length > 0 ? { entries } : {},
952
+ studies,
953
+ seed,
954
+ ...validated.layout !== void 0 ? { layout: validated.layout } : {}
955
+ };
956
+ }
796
957
  export {
797
958
  caseCachePath,
798
959
  defaultPublicCasesPath,
@@ -802,7 +963,9 @@ export {
802
963
  loadCaseById,
803
964
  loadCasesFromJson,
804
965
  loadDefaultCases,
966
+ resolveParametricSpec,
805
967
  validateDatasetSpec,
968
+ validateParametricSpec,
806
969
  verifySha256,
807
970
  writeCollectionFromSpec
808
971
  };
@@ -0,0 +1,327 @@
1
+ // src/schema/parametric.ts
2
+ import { randomBytes as randomBytes2 } from "node:crypto";
3
+
4
+ // src/syntheticFixtures/uid.ts
5
+ import { randomBytes } from "node:crypto";
6
+ function lcg(seed) {
7
+ let s = seed >>> 0;
8
+ return () => {
9
+ s = Math.imul(1664525, s) + 1013904223 >>> 0;
10
+ return s;
11
+ };
12
+ }
13
+ var PARAMETRIC_STREAM_OFFSET = 2246822507;
14
+ function seededStream(seed, offset, a, b) {
15
+ return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
16
+ }
17
+
18
+ // src/schema/validate.ts
19
+ var TYPE_CAPABILITIES = {
20
+ "valid-image": { image: true },
21
+ "invalid-uid-image": { image: true },
22
+ "vendor-warnings-image": { image: true },
23
+ "fake-signature": { image: false },
24
+ "non-dicom": { image: false },
25
+ dicomdir: { image: false }
26
+ };
27
+ var VALID_MODALITIES = {
28
+ CT: true,
29
+ PT: true,
30
+ MR: true,
31
+ CR: true
32
+ };
33
+ var VALID_LAYOUTS = {
34
+ flat: true,
35
+ hierarchical: true
36
+ };
37
+ var VALID_TRANSFER_SYNTAXES = {
38
+ "explicit-vr-little-endian": true,
39
+ "implicit-vr-little-endian": true
40
+ };
41
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
42
+ var VALID_VIOLATIONS = {
43
+ "uid-too-long": true,
44
+ "non-conformant-uid": true,
45
+ "missing-meta-header": true,
46
+ "malformed-sq-delimiter": true,
47
+ "vr-max-length-exceeded": true,
48
+ "missing-type1-tag": true
49
+ };
50
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
51
+ var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
52
+ function validatePositiveInt(value, path) {
53
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
54
+ throw new Error(
55
+ `${path}: must be a positive integer; got ${JSON.stringify(value)}`
56
+ );
57
+ }
58
+ }
59
+ function validateSeed(value, path) {
60
+ if (typeof value !== "number" || !Number.isFinite(value)) {
61
+ throw new Error(
62
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
63
+ );
64
+ }
65
+ }
66
+ function validateSizeKbLimit(kb, path) {
67
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
68
+ throw new Error(`${path}: exceeds the 512 MB limit`);
69
+ }
70
+ }
71
+ function validateEnum(value, valid, path) {
72
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
73
+ throw new Error(
74
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
75
+ );
76
+ }
77
+ }
78
+ function validateTagKey(key, path) {
79
+ if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
80
+ throw new Error(
81
+ `${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
82
+ );
83
+ }
84
+ }
85
+ function validateEntry(entry, path) {
86
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
87
+ throw new Error(`${path}: must be an object`);
88
+ }
89
+ const e = entry;
90
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
91
+ const type = e.type;
92
+ const caps = TYPE_CAPABILITIES[type];
93
+ if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
94
+ for (const field of [
95
+ "modality",
96
+ "rows",
97
+ "columns",
98
+ "frames",
99
+ "targetSizeKb",
100
+ "tags",
101
+ "violations",
102
+ "transferSyntax"
103
+ ]) {
104
+ if (!caps.image && field in e)
105
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
106
+ }
107
+ if ("transferSyntax" in e) {
108
+ validateEnum(
109
+ e.transferSyntax,
110
+ VALID_TRANSFER_SYNTAXES,
111
+ `${path}.transferSyntax`
112
+ );
113
+ }
114
+ if ("modality" in e) {
115
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
116
+ }
117
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
118
+ if ("violations" in e) {
119
+ if (!Array.isArray(e.violations)) {
120
+ throw new Error(`${path}.violations: must be an array`);
121
+ }
122
+ for (const [i, v] of e.violations.entries()) {
123
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
124
+ }
125
+ }
126
+ const hasDims = "rows" in e || "columns" in e || "frames" in e;
127
+ if ("targetSizeKb" in e) {
128
+ if (hasDims) {
129
+ throw new Error(
130
+ `${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
131
+ );
132
+ }
133
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
134
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
135
+ } else if (hasDims) {
136
+ if (!("rows" in e) || !("columns" in e)) {
137
+ throw new Error(`${path}: rows and columns must be provided together`);
138
+ }
139
+ validatePositiveInt(e.rows, `${path}.rows`);
140
+ validatePositiveInt(e.columns, `${path}.columns`);
141
+ if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
142
+ const rows = e.rows;
143
+ const columns = e.columns;
144
+ const frames = typeof e.frames === "number" ? e.frames : 1;
145
+ if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
146
+ throw new Error(
147
+ `${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
148
+ );
149
+ }
150
+ }
151
+ return e;
152
+ }
153
+ function validateTags(tags, path) {
154
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
155
+ throw new Error(`${path}: must be an object`);
156
+ }
157
+ for (const key of Object.keys(tags)) {
158
+ validateTagKey(key, path);
159
+ }
160
+ }
161
+ function validateRange(value, path) {
162
+ if (typeof value === "number") {
163
+ validatePositiveInt(value, path);
164
+ return value;
165
+ }
166
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
167
+ throw new Error(
168
+ `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
169
+ );
170
+ }
171
+ const r = value;
172
+ validatePositiveInt(r.min, `${path}.min`);
173
+ validatePositiveInt(r.max, `${path}.max`);
174
+ if (r.min > r.max) {
175
+ throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
176
+ }
177
+ return value;
178
+ }
179
+ function rangeMax(range) {
180
+ return typeof range === "number" ? range : range.max;
181
+ }
182
+ function validateParametricStudies(studies, path) {
183
+ if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
184
+ throw new Error(`${path}: must be an object`);
185
+ }
186
+ const s = studies;
187
+ for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
188
+ if (!(field in s)) throw new Error(`${path}.${field}: is required`);
189
+ validateRange(s[field], `${path}.${field}`);
190
+ }
191
+ if ("fileSizeKb" in s) {
192
+ validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
193
+ validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
194
+ }
195
+ if ("modalities" in s) {
196
+ if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
197
+ throw new Error(`${path}.modalities: must be a non-empty array`);
198
+ }
199
+ for (const [i, m] of s.modalities.entries()) {
200
+ validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
201
+ }
202
+ }
203
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
204
+ return s;
205
+ }
206
+ function validateEdgeCase(edgeCase, path) {
207
+ const e = validateEntry(edgeCase, path);
208
+ if ("frequency" in e) {
209
+ if ("count" in e) {
210
+ throw new Error(
211
+ `${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
212
+ );
213
+ }
214
+ const f = e.frequency;
215
+ if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
216
+ throw new Error(
217
+ `${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
218
+ );
219
+ }
220
+ }
221
+ return e;
222
+ }
223
+ function validateParametricSpec(raw) {
224
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
225
+ throw new Error("ParametricSpec: must be a JSON object");
226
+ }
227
+ const r = raw;
228
+ if (!("studies" in r)) {
229
+ throw new Error("ParametricSpec.studies: is required");
230
+ }
231
+ const studies = validateParametricStudies(r.studies, "studies");
232
+ const edgeCases = [];
233
+ if ("edgeCases" in r) {
234
+ if (!Array.isArray(r.edgeCases)) {
235
+ throw new Error("ParametricSpec.edgeCases: must be an array");
236
+ }
237
+ for (const [i, e] of r.edgeCases.entries()) {
238
+ edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
239
+ }
240
+ }
241
+ if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
242
+ if ("layout" in r) {
243
+ validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
244
+ }
245
+ return {
246
+ studies,
247
+ ...edgeCases.length > 0 ? { edgeCases } : {},
248
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
249
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
250
+ };
251
+ }
252
+
253
+ // src/schema/parametric.ts
254
+ function sample(next, range) {
255
+ if (typeof range === "number") return range;
256
+ return range.min + next() % (range.max - range.min + 1);
257
+ }
258
+ function pick(next, items) {
259
+ return items[next() % items.length];
260
+ }
261
+ function fraction(next) {
262
+ return next() / 4294967296;
263
+ }
264
+ function resolveParametricSpec(spec) {
265
+ const validated = validateParametricSpec(spec);
266
+ const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
267
+ const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
268
+ const params = validated.studies;
269
+ const studies = [];
270
+ let totalFiles = 0;
271
+ const studyCount = sample(next, params.count);
272
+ for (let st = 0; st < studyCount; st++) {
273
+ const seriesCount = sample(next, params.seriesPerStudy);
274
+ const series = [];
275
+ for (let se = 0; se < seriesCount; se++) {
276
+ const fileCount = sample(next, params.filesPerSeries);
277
+ totalFiles += fileCount;
278
+ const modality = params.modalities ? pick(next, params.modalities) : void 0;
279
+ const base = {
280
+ type: "valid-image",
281
+ ...modality !== void 0 ? { modality } : {}
282
+ };
283
+ const entries2 = [];
284
+ if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
285
+ for (let f = 0; f < fileCount; f++) {
286
+ entries2.push({
287
+ ...base,
288
+ targetSizeKb: sample(next, params.fileSizeKb)
289
+ });
290
+ }
291
+ } else {
292
+ entries2.push({
293
+ ...base,
294
+ ...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
295
+ ...fileCount > 1 ? { count: fileCount } : {}
296
+ });
297
+ }
298
+ series.push({ entries: entries2 });
299
+ }
300
+ studies.push({
301
+ series,
302
+ ...params.tags !== void 0 ? { tags: params.tags } : {}
303
+ });
304
+ }
305
+ const entries = [];
306
+ for (const edgeCase of validated.edgeCases ?? []) {
307
+ const { frequency, ...entry } = edgeCase;
308
+ if (frequency === void 0) {
309
+ entries.push(entry);
310
+ continue;
311
+ }
312
+ let drawn = 0;
313
+ for (let i = 0; i < totalFiles; i++) {
314
+ if (fraction(next) < frequency) drawn++;
315
+ }
316
+ if (drawn > 0) entries.push({ ...entry, count: drawn });
317
+ }
318
+ return {
319
+ ...entries.length > 0 ? { entries } : {},
320
+ studies,
321
+ seed,
322
+ ...validated.layout !== void 0 ? { layout: validated.layout } : {}
323
+ };
324
+ }
325
+ export {
326
+ resolveParametricSpec
327
+ };
@@ -39,6 +39,25 @@ function validatePositiveInt(value, path) {
39
39
  );
40
40
  }
41
41
  }
42
+ function validateSeed(value, path) {
43
+ if (typeof value !== "number" || !Number.isFinite(value)) {
44
+ throw new Error(
45
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
46
+ );
47
+ }
48
+ }
49
+ function validateSizeKbLimit(kb, path) {
50
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
51
+ throw new Error(`${path}: exceeds the 512 MB limit`);
52
+ }
53
+ }
54
+ function validateEnum(value, valid, path) {
55
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
56
+ throw new Error(
57
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
58
+ );
59
+ }
60
+ }
42
61
  function validateTagKey(key, path) {
43
62
  if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
44
63
  throw new Error(
@@ -51,11 +70,7 @@ function validateEntry(entry, path) {
51
70
  throw new Error(`${path}: must be an object`);
52
71
  }
53
72
  const e = entry;
54
- if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
55
- throw new Error(
56
- `${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
57
- );
58
- }
73
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
59
74
  const type = e.type;
60
75
  const caps = TYPE_CAPABILITIES[type];
61
76
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
@@ -73,18 +88,14 @@ function validateEntry(entry, path) {
73
88
  throw new Error(`${path}.${field}: not supported for type "${type}"`);
74
89
  }
75
90
  if ("transferSyntax" in e) {
76
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
77
- throw new Error(
78
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
79
- );
80
- }
91
+ validateEnum(
92
+ e.transferSyntax,
93
+ VALID_TRANSFER_SYNTAXES,
94
+ `${path}.transferSyntax`
95
+ );
81
96
  }
82
97
  if ("modality" in e) {
83
- if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
84
- throw new Error(
85
- `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
86
- );
87
- }
98
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
88
99
  }
89
100
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
90
101
  if ("violations" in e) {
@@ -92,11 +103,7 @@ function validateEntry(entry, path) {
92
103
  throw new Error(`${path}.violations: must be an array`);
93
104
  }
94
105
  for (const [i, v] of e.violations.entries()) {
95
- if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
96
- throw new Error(
97
- `${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
98
- );
99
- }
106
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
100
107
  }
101
108
  }
102
109
  const hasDims = "rows" in e || "columns" in e || "frames" in e;
@@ -107,9 +114,7 @@ function validateEntry(entry, path) {
107
114
  );
108
115
  }
109
116
  validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
110
- if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
111
- throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
112
- }
117
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
113
118
  } else if (hasDims) {
114
119
  if (!("rows" in e) || !("columns" in e)) {
115
120
  throw new Error(`${path}: rows and columns must be provided together`);
@@ -194,28 +199,110 @@ function validateDatasetSpec(raw) {
194
199
  const studies = rawStudies.map(
195
200
  (study, i) => validateStudy(study, `studies[${i}]`)
196
201
  );
197
- if ("seed" in r) {
198
- if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
202
+ if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
203
+ if ("layout" in r) {
204
+ validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
205
+ }
206
+ return {
207
+ ...entries.length > 0 ? { entries } : {},
208
+ ...studies.length > 0 ? { studies } : {},
209
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
210
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
211
+ };
212
+ }
213
+ function validateRange(value, path) {
214
+ if (typeof value === "number") {
215
+ validatePositiveInt(value, path);
216
+ return value;
217
+ }
218
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
219
+ throw new Error(
220
+ `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
221
+ );
222
+ }
223
+ const r = value;
224
+ validatePositiveInt(r.min, `${path}.min`);
225
+ validatePositiveInt(r.max, `${path}.max`);
226
+ if (r.min > r.max) {
227
+ throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
228
+ }
229
+ return value;
230
+ }
231
+ function rangeMax(range) {
232
+ return typeof range === "number" ? range : range.max;
233
+ }
234
+ function validateParametricStudies(studies, path) {
235
+ if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
236
+ throw new Error(`${path}: must be an object`);
237
+ }
238
+ const s = studies;
239
+ for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
240
+ if (!(field in s)) throw new Error(`${path}.${field}: is required`);
241
+ validateRange(s[field], `${path}.${field}`);
242
+ }
243
+ if ("fileSizeKb" in s) {
244
+ validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
245
+ validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
246
+ }
247
+ if ("modalities" in s) {
248
+ if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
249
+ throw new Error(`${path}.modalities: must be a non-empty array`);
250
+ }
251
+ for (const [i, m] of s.modalities.entries()) {
252
+ validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
253
+ }
254
+ }
255
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
256
+ return s;
257
+ }
258
+ function validateEdgeCase(edgeCase, path) {
259
+ const e = validateEntry(edgeCase, path);
260
+ if ("frequency" in e) {
261
+ if ("count" in e) {
199
262
  throw new Error(
200
- `DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
263
+ `${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
201
264
  );
202
265
  }
203
- }
204
- if ("layout" in r) {
205
- if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
266
+ const f = e.frequency;
267
+ if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
206
268
  throw new Error(
207
- `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
269
+ `${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
208
270
  );
209
271
  }
210
272
  }
273
+ return e;
274
+ }
275
+ function validateParametricSpec(raw) {
276
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
277
+ throw new Error("ParametricSpec: must be a JSON object");
278
+ }
279
+ const r = raw;
280
+ if (!("studies" in r)) {
281
+ throw new Error("ParametricSpec.studies: is required");
282
+ }
283
+ const studies = validateParametricStudies(r.studies, "studies");
284
+ const edgeCases = [];
285
+ if ("edgeCases" in r) {
286
+ if (!Array.isArray(r.edgeCases)) {
287
+ throw new Error("ParametricSpec.edgeCases: must be an array");
288
+ }
289
+ for (const [i, e] of r.edgeCases.entries()) {
290
+ edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
291
+ }
292
+ }
293
+ if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
294
+ if ("layout" in r) {
295
+ validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
296
+ }
211
297
  return {
212
- ...entries.length > 0 ? { entries } : {},
213
- ...studies.length > 0 ? { studies } : {},
298
+ studies,
299
+ ...edgeCases.length > 0 ? { edgeCases } : {},
214
300
  ...r.seed !== void 0 ? { seed: r.seed } : {},
215
301
  ...r.layout !== void 0 ? { layout: r.layout } : {}
216
302
  };
217
303
  }
218
304
  export {
219
305
  HEX_TAG_RE,
220
- validateDatasetSpec
306
+ validateDatasetSpec,
307
+ validateParametricSpec
221
308
  };
@@ -19,6 +19,7 @@ function seededUid(next, role) {
19
19
  return formatUid(next(), next(), role);
20
20
  }
21
21
  var GROUP_STREAM_OFFSET = 2654435769;
22
+ var PARAMETRIC_STREAM_OFFSET = 2246822507;
22
23
  function seededStream(seed, offset, a, b) {
23
24
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
24
25
  }
@@ -63,6 +64,8 @@ function makeUidGenerator(seed) {
63
64
  };
64
65
  }
65
66
  export {
67
+ PARAMETRIC_STREAM_OFFSET,
66
68
  makeGroupUidGenerator,
67
- makeUidGenerator
69
+ makeUidGenerator,
70
+ seededStream
68
71
  };
@@ -1,5 +1,6 @@
1
1
  export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
2
2
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
3
3
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
4
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
5
- export { validateDatasetSpec } from './schema/validate.js';
4
+ export { resolveParametricSpec } from './schema/parametric.js';
5
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
6
+ export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
@@ -0,0 +1,2 @@
1
+ import type { DatasetSpec, ParametricSpec } from './types.js';
2
+ export declare function resolveParametricSpec(spec: ParametricSpec): DatasetSpec;
@@ -61,4 +61,25 @@ export type DatasetSpec = {
61
61
  seed?: number;
62
62
  layout?: DatasetLayout;
63
63
  };
64
+ export type Range = number | {
65
+ min: number;
66
+ max: number;
67
+ };
68
+ export type ParametricStudies = {
69
+ count: Range;
70
+ seriesPerStudy: Range;
71
+ filesPerSeries: Range;
72
+ fileSizeKb?: Range;
73
+ modalities?: Modality[];
74
+ tags?: DicomTagOverrides;
75
+ };
76
+ export type ParametricEdgeCase = EntrySpec & {
77
+ frequency?: number;
78
+ };
79
+ export type ParametricSpec = {
80
+ seed?: number;
81
+ studies: ParametricStudies;
82
+ edgeCases?: ParametricEdgeCase[];
83
+ layout?: DatasetLayout;
84
+ };
64
85
  export {};
@@ -1,3 +1,4 @@
1
- import type { DatasetSpec } from './types.js';
1
+ import type { DatasetSpec, ParametricSpec } from './types.js';
2
2
  export declare const HEX_TAG_RE: RegExp;
3
3
  export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
4
+ export declare function validateParametricSpec(raw: unknown): ParametricSpec;
@@ -3,6 +3,8 @@ export type UidSet = {
3
3
  series: string;
4
4
  sop: string;
5
5
  };
6
+ export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
7
+ export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
6
8
  export type GroupUidGenerator = {
7
9
  study: (studyIndex: number) => string;
8
10
  series: (studyIndex: number, seriesIndex: number) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",