diagcalc 3.2.3 → 5.0.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
@@ -10,7 +10,7 @@ Both interfaces use the same calculation engine.
10
10
  ## Repository
11
11
 
12
12
  - GitHub: `https://github.com/tiagojct/diagcalc`
13
- - Web app: `https://diagcalc.tiagojct.eu/`
13
+ - Web app: `https://diagcalc.tiagojacinto.eu/`
14
14
  - npm package: `https://www.npmjs.com/package/diagcalc`
15
15
 
16
16
  ## Description
@@ -47,11 +47,28 @@ The web and terminal interfaces both follow that workflow.
47
47
  - sensitivity, specificity, PPV, NPV
48
48
  - LR+ and LR-
49
49
  - positive and negative post-test probability
50
- - 95% confidence intervals with the Wilson method
51
- - preset study scenarios
50
+ - Wilson 95% intervals for proportions and log-normal intervals for likelihood ratios and DOR
51
+ - illustrative teaching scenarios with explicit provenance
52
52
  - Fagan nomogram in the web app
53
53
  - TUI, text CLI, and JSON CLI output in the terminal app
54
54
 
55
+ ## Case Studies
56
+
57
+ All current presets are illustrative teaching scenarios. Medical references provide background; their confusion-matrix counts have not been verified against retained source-table extractions. They must not be presented as published study estimates.
58
+
59
+ - `screening` - low-prevalence population screening workflow
60
+ - `caseControl` - balanced case-control teaching example
61
+ - `clinic` - specialist clinic setting with intermediate prevalence
62
+ - `ddimer` - D-dimer for pulmonary embolism rule-out
63
+ - `troponin` - high-sensitivity troponin for acute myocardial infarction
64
+ - `mammography` - screening mammography in a low-prevalence setting
65
+ - `covid_antigen` - rapid antigen testing for COVID-19
66
+ - `hiv_elisa` - fourth-generation HIV ELISA screening
67
+ - `strep_throat` - rapid antigen testing for streptococcal pharyngitis
68
+ - `xray_pneumonia` - chest X-ray for community-acquired pneumonia
69
+
70
+ These cases are included to support teaching across different prevalence settings, screening vs. confirmation logic, and Bayesian interpretation of test results.
71
+
55
72
  ## Web App
56
73
 
57
74
  The web app is static. It does not need a build step.
@@ -82,7 +99,7 @@ http://localhost:8080
82
99
 
83
100
  ## Terminal App
84
101
 
85
- The terminal app requires Node.js 18 or newer.
102
+ The terminal app requires Node.js 22 or newer.
86
103
 
87
104
  It is intended for keyboard-first use, quick calculations, reproducible terminal workflows, and scripting.
88
105
 
@@ -148,6 +165,38 @@ The CLI is useful when you want a one-shot calculation or when you want to integ
148
165
  - `r`: reset current case
149
166
  - `q`: quit
150
167
 
168
+ ## Validation and reproducibility
169
+
170
+ Counts must be non-negative safe integers, including their total. Both disease cohorts must be present. Pre-test probability accepts complete dot/comma decimal strings from 0 through 100 inclusive. Impossible conditioning events remain undefined and display an em dash; infinite ratios display infinity. Chaining retains full precision and assumes tests are conditionally independent given disease status.
171
+
172
+ The browser's history retains the inputs, continuity correction, engine version and origin for each calculation. Editing a preset marks it customised and invalidates previous results. Storage restrictions fall back to session memory; they do not prevent calculations. Older history entries are explicitly marked as recalculated legacy cases because their original correction settings were not recorded.
173
+
174
+ ### Version 5 JSON compatibility
175
+
176
+ `--format json` now emits schema version 2. Every metric and CI endpoint encodes exceptional numbers explicitly:
177
+
178
+ ```json
179
+ {"value": null, "status": "infinite", "ci": null}
180
+ ```
181
+
182
+ Statuses are `finite`, `infinite`, `negative-infinite`, and `undefined`; finite values are unrounded. Consumers of the previous JSON format must migrate. Outputs include engine version, inputs, correction options and provenance. TUI text/Markdown exports append a reproducible JSON snapshot and refuse to overwrite existing paths.
183
+
184
+ ROC rows must represent the same cohorts with monotonic operating points and consistently ordered cutoffs. Synthetic points and edited synthetic points remain labelled illustrative, including their AUC. Decision thresholds are unavailable for uninformative or inverted tests. Nomogram lines outside its labelled axis range are explicitly omitted; numeric probabilities remain available.
185
+
186
+ ## Development checks
187
+
188
+ ```bash
189
+ npm ci
190
+ npx playwright install chromium firefox webkit
191
+ npm run check
192
+ ```
193
+
194
+ `check` runs JavaScript type checking, engine/CLI/TUI/package tests and browser tests in Chromium, Firefox and WebKit. Browser tests prepare the actual static deployment bundle automatically. Python 3 and a pseudo-terminal are required for the real terminal test on Unix; that test is skipped on Windows. There are no runtime browser dependencies or compilation step.
195
+
196
+ Pull requests run checks on Node 22 and 24 and all three browser engines. Main-branch deployment requires both verification jobs. `npm run prepare:site` copies only browser assets and `CNAME` into `public/`; package tests also extract and execute the actual npm archive outside the checkout.
197
+
198
+ See [lib/README.md](lib/README.md) for API contracts and [docs/PROVENANCE.md](docs/PROVENANCE.md) for dataset evidence requirements.
199
+
151
200
  ## Deployment
152
201
 
153
202
  ### GitHub Pages
@@ -187,8 +236,13 @@ Package page:
187
236
 
188
237
  - `index.html` - web app markup
189
238
  - `styles.css` - web app styles
190
- - `script.js` - web app logic and Fagan nomogram rendering
191
- - `lib/diagcalc-core.js` - shared calculations and validation
239
+ - `script.js` - web state and event handling
240
+ - `web/` - result rendering and scheduled DPR-aware charts
241
+ - `lib/diagcalc-core.js` - shared numeric calculations and validation
242
+ - `lib/diagcalc-presentation.js` - English/Portuguese metric labels and interpretation
243
+ - `lib/diagcalc-case.js` - versioned snapshots and safe exceptional-number encoding
244
+ - `lib/diagcalc-storage.js` - resilient browser preferences and session fallback
245
+ - `lib/diagcalc-geometry.js` - independently testable nomogram geometry
192
246
  - `lib/diagcalc-datasets.js` - shared preset datasets
193
247
  - `tui/index.js` - terminal UI
194
248
  - `bin/diagcalc.js` - CLI and TUI entrypoint
package/bin/diagcalc.js CHANGED
@@ -2,32 +2,32 @@
2
2
 
3
3
  const core = require("../lib/diagcalc-core");
4
4
  const datasetStore = require("../lib/diagcalc-datasets");
5
+ const pkg = require("../package.json");
5
6
 
6
- function parseArgs(argv) {
7
- const args = {
8
- raw: [],
9
- };
7
+ const caseStore = require("../lib/diagcalc-case");
10
8
 
9
+ function parseArgs(argv) {
10
+ const flags = new Set(["help", "tui", "chain", "list-datasets"]);
11
+ const values = new Set(["dataset", "tp", "fp", "fn", "tn", "pre", "tp2", "fp2", "fn2", "tn2", "chain-from", "format", "continuity"]);
12
+ const args = {};
11
13
  for (let index = 0; index < argv.length; index += 1) {
12
14
  const token = argv[index];
13
- if (!token.startsWith("--")) {
14
- args.raw.push(token);
15
- continue;
16
- }
17
-
18
- const key = token.slice(2);
19
- const next = argv[index + 1];
20
- const isFlag = !next || next.startsWith("--");
21
-
22
- if (isFlag) {
15
+ if (!token.startsWith("--")) return { error: `Unexpected argument: ${token}` };
16
+ const [key, ...parts] = token.slice(2).split("=");
17
+ if (!flags.has(key) && !values.has(key)) return { error: `Unknown option: --${key}` };
18
+ if (Object.prototype.hasOwnProperty.call(args, key)) return { error: `Duplicate option: --${key}` };
19
+ if (flags.has(key)) {
20
+ if (parts.length) return { error: `--${key} does not take a value.` };
23
21
  args[key] = true;
24
22
  continue;
25
23
  }
26
-
27
- args[key] = next;
28
- index += 1;
24
+ const value = parts.length ? parts.join("=") : argv[++index];
25
+ if (value === undefined || value === "" || value.startsWith("--")) return { error: `Missing value for --${key}.` };
26
+ args[key] = value;
27
+ }
28
+ if (!args.chain && ["tp2", "fp2", "fn2", "tn2", "chain-from"].some((key) => key in args)) {
29
+ return { error: "Second-test options require --chain." };
29
30
  }
30
-
31
31
  return args;
32
32
  }
33
33
 
@@ -40,6 +40,7 @@ function printHelp() {
40
40
  " diag --dataset hiv_elisa",
41
41
  " diag --tp 199 --fp 1 --fn 1 --tn 9799 --pre 2",
42
42
  " diag --dataset ddimer --pre 18",
43
+ " diag --dataset ddimer --chain --tp2 90 --fp2 10 --fn2 10 --tn2 90",
43
44
  " diag --list-datasets",
44
45
  "",
45
46
  "Options:",
@@ -50,7 +51,11 @@ function printHelp() {
50
51
  " --fn <n> False negatives",
51
52
  " --tn <n> True negatives",
52
53
  " --pre <n> Pre-test probability (%)",
54
+ " --chain Compute a second test using test 1's post-test as pre-test",
55
+ " --tp2 --fp2 --fn2 --tn2 Second test's confusion matrix (with --chain)",
56
+ " --chain-from <r> positive (default) | negative — which test 1 result to follow",
53
57
  " --format <type> Output format: text or json",
58
+ " --continuity <m> Continuity correction for LR/DOR CIs: auto (default), always, never",
54
59
  " --list-datasets Show available dataset keys",
55
60
  " --help Show this help message",
56
61
  "",
@@ -60,24 +65,32 @@ function printHelp() {
60
65
  ].join("\n"));
61
66
  }
62
67
 
63
- function serialiseMetric(metric) {
64
- return {
65
- label: metric.label,
66
- value: metric.value,
67
- formatted: core.formatValue(metric.value, metric.formatter),
68
- ci: metric.ci || null,
69
- note: metric.note || null,
70
- };
71
- }
72
-
73
- function printJsonReport(dataset, input, metrics) {
68
+ function printJsonReport(dataset, input, metrics, chained, warnings, continuity) {
69
+ const modified = Boolean(dataset && ["tp", "fp", "fn", "tn", "preTestProb"].some((key) => input[key] !== dataset[key]));
70
+ const snapshot = caseStore.createSnapshot(input, { continuityCorrection: continuity }, {
71
+ label: dataset ? `${modified ? "Customised: " : ""}${dataset.name}` : "Ad hoc case",
72
+ datasetKey: dataset ? dataset.key : null,
73
+ modified,
74
+ provenance: dataset ? dataset.provenance : null,
75
+ });
74
76
  const payload = {
75
- case: dataset ? dataset.name : "Ad hoc case",
76
- datasetKey: dataset ? dataset.key || null : null,
77
- input,
78
- metrics: Object.fromEntries(Object.entries(metrics).map(([key, metric]) => [key, serialiseMetric(metric)])),
77
+ ...snapshot,
78
+ engine: { name: "diagcalc", version: pkg.version, generatedAt: snapshot.savedAt, ciMethods: caseStore.methodMetadata(snapshot.options) },
79
+ case: snapshot.label,
80
+ warnings,
81
+ metrics: Object.fromEntries(Object.entries(metrics).map(([key, metric]) => [key, {
82
+ ...snapshot.metrics[key], label: metric.label,
83
+ formatted: core.formatValue(metric.value, metric.formatter), note: metric.note,
84
+ }])),
79
85
  };
80
-
86
+ if (chained) {
87
+ payload.chained = {
88
+ ...caseStore.createSnapshot(chained.input, { continuityCorrection: continuity }, { label: "Chained second test" }),
89
+ from: chained.from,
90
+ assumption: "Test results are conditionally independent given disease status.",
91
+ warnings: core.buildBiasWarnings(chained.input),
92
+ };
93
+ }
81
94
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
82
95
  }
83
96
 
@@ -115,8 +128,7 @@ function buildInputFromArgs(args) {
115
128
  merged.tn = core.safeParseInt(args.tn);
116
129
  }
117
130
  if (typeof args.pre === "string") {
118
- const normalised = core.normaliseDecimal(args.pre);
119
- merged.preTestProb = normalised === "" ? NaN : parseFloat(normalised);
131
+ merged.preTestProb = core.parseProbability(args.pre);
120
132
  }
121
133
 
122
134
  return {
@@ -128,17 +140,19 @@ function buildInputFromArgs(args) {
128
140
  function renderMetricLine(metric) {
129
141
  const value = core.formatValue(metric.value, metric.formatter);
130
142
  const ci = metric.ci
131
- ? ` | 95% CI ${core.formatPercentage(metric.ci.lower)} to ${core.formatPercentage(metric.ci.upper)}`
143
+ ? ` | 95% CI ${core.formatValue(metric.ci.lower, metric.formatter)} to ${core.formatValue(metric.ci.upper, metric.formatter)}`
132
144
  : "";
133
145
  return `${metric.label}: ${value}${ci}`;
134
146
  }
135
147
 
136
- function printReport(dataset, metrics) {
148
+ function printReport(dataset, metrics, warnings) {
137
149
  const entries = [
138
150
  metrics.sensitivity,
139
151
  metrics.specificity,
140
152
  metrics.ppv,
141
153
  metrics.npv,
154
+ metrics.dor,
155
+ metrics.numberNeededToScreen,
142
156
  metrics.lrPositive,
143
157
  metrics.lrNegative,
144
158
  metrics.preTestProbability,
@@ -152,6 +166,13 @@ function printReport(dataset, metrics) {
152
166
  lines.push(dataset.name);
153
167
  }
154
168
  lines.push("");
169
+ if (Array.isArray(warnings) && warnings.length > 0) {
170
+ lines.push("Heads up:");
171
+ for (const w of warnings) {
172
+ lines.push(` - ${w}`);
173
+ }
174
+ lines.push("");
175
+ }
155
176
  entries.forEach((metric) => {
156
177
  lines.push(renderMetricLine(metric));
157
178
  });
@@ -169,6 +190,11 @@ function printReport(dataset, metrics) {
169
190
 
170
191
  function main() {
171
192
  const args = parseArgs(process.argv.slice(2));
193
+ if (args.error) {
194
+ process.stderr.write(`${args.error}\n`);
195
+ process.exitCode = 1;
196
+ return;
197
+ }
172
198
 
173
199
  if (args.help) {
174
200
  printHelp();
@@ -205,10 +231,34 @@ function main() {
205
231
  return;
206
232
  }
207
233
 
208
- const metrics = core.calculateMetrics(built.input);
234
+ const continuity = typeof args.continuity === "string" ? args.continuity.toLowerCase() : "auto";
235
+ if (!["auto", "always", "never"].includes(continuity)) {
236
+ process.stderr.write(`Invalid --continuity value. Use auto, always, or never.\n`);
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+ const metrics = core.calculateMetrics(built.input, { continuityCorrection: continuity });
241
+ if (args.format !== undefined && typeof args.format !== "string") {
242
+ process.stderr.write("Missing value for --format. Use --format text or --format json.\n");
243
+ process.exitCode = 1;
244
+ return;
245
+ }
209
246
  const format = typeof args.format === "string" ? args.format.toLowerCase() : "text";
247
+
248
+ let chained = null;
249
+ if (args.chain) {
250
+ chained = buildChainedTest(args, metrics, continuity);
251
+ if (chained.error) {
252
+ process.stderr.write(`${chained.error}\n`);
253
+ process.exitCode = 1;
254
+ return;
255
+ }
256
+ }
257
+
258
+ const warnings = [...core.buildBiasWarnings(built.input), ...datasetStore.buildDatasetWarnings(built.dataset)];
259
+
210
260
  if (format === "json") {
211
- printJsonReport(built.dataset, built.input, metrics);
261
+ printJsonReport(built.dataset, built.input, metrics, chained, warnings, continuity);
212
262
  return;
213
263
  }
214
264
 
@@ -218,7 +268,63 @@ function main() {
218
268
  return;
219
269
  }
220
270
 
221
- printReport(built.dataset, metrics);
271
+ const modified = built.dataset && ["tp", "fp", "fn", "tn", "preTestProb"].some((key) => built.input[key] !== built.dataset[key]);
272
+ printReport(built.dataset ? { ...built.dataset, name: `${modified ? "Customised: " : ""}${built.dataset.name}` } : null, metrics, warnings);
273
+ process.stdout.write(`Engine ${pkg.version}; continuity correction: ${continuity}.\n`);
274
+ if (chained) {
275
+ printChainedReport(chained);
276
+ }
277
+ }
278
+
279
+ function buildChainedTest(args, firstMetrics, continuity) {
280
+ const from = typeof args["chain-from"] === "string" ? args["chain-from"].toLowerCase() : "positive";
281
+ if (from !== "positive" && from !== "negative") {
282
+ return { error: "Invalid --chain-from. Use 'positive' or 'negative'." };
283
+ }
284
+ const sourceProb = from === "negative" ? firstMetrics.postTestNegative.value : firstMetrics.postTestPositive.value;
285
+ if (!Number.isFinite(sourceProb)) {
286
+ return { error: "Test 1's post-test probability is not finite; cannot chain." };
287
+ }
288
+ const preTestProb = core.chainedPreTestProbability(sourceProb);
289
+
290
+ const input = {
291
+ tp: typeof args.tp2 === "string" ? core.safeParseInt(args.tp2) : NaN,
292
+ fp: typeof args.fp2 === "string" ? core.safeParseInt(args.fp2) : NaN,
293
+ fn: typeof args.fn2 === "string" ? core.safeParseInt(args.fn2) : NaN,
294
+ tn: typeof args.tn2 === "string" ? core.safeParseInt(args.tn2) : NaN,
295
+ preTestProb,
296
+ };
297
+ const validation = core.validateInputs(input);
298
+ if (!validation.valid) {
299
+ return { error: `Test 2 validation failed: ${validation.message}` };
300
+ }
301
+ return {
302
+ from,
303
+ input,
304
+ metrics: core.calculateMetrics(input, { continuityCorrection: continuity }),
305
+ };
306
+ }
307
+
308
+ function printChainedReport(chained) {
309
+ process.stdout.write(`\n— Chained second test (following test 1's ${chained.from} result) —\n`);
310
+ process.stdout.write("Assumption: results are conditionally independent given disease status.\n");
311
+ process.stdout.write(`Test 2 pre-test probability: ${core.formatPercentage(chained.input.preTestProb / 100)}\n\n`);
312
+ const entries = [
313
+ chained.metrics.sensitivity,
314
+ chained.metrics.specificity,
315
+ chained.metrics.ppv,
316
+ chained.metrics.npv,
317
+ chained.metrics.dor,
318
+ chained.metrics.numberNeededToScreen,
319
+ chained.metrics.lrPositive,
320
+ chained.metrics.lrNegative,
321
+ chained.metrics.postTestPositive,
322
+ chained.metrics.postTestNegative,
323
+ ];
324
+ entries.forEach((metric) => {
325
+ process.stdout.write(`${renderMetricLine(metric)}\n`);
326
+ });
327
+ process.stdout.write("\n");
222
328
  }
223
329
 
224
330
  main();
@@ -0,0 +1,17 @@
1
+ # Dataset provenance
2
+
3
+ All existing presets are classified `illustrative`. The repository does not retain a source table or extraction record establishing that the TP/FP/FN/TN values came from the cited medical papers. Keeping a DOI or a reference review date does not establish numerical provenance. The reference dates are therefore named `referenceLastReviewed`.
4
+
5
+ Each frozen preset has a `provenance` record: `kind`, `sourceLocation`, `extraction`, `threshold`, `referenceStandard`, `outcome`, `population`, `reviewStatus`, and `note`. Null fields mean evidence has not been retained. Browser warnings, terminal warnings and JSON snapshots carry this distinction; customised cases retain their original source identity and mark their counts modified.
6
+
7
+ To add a verified empirical case, retain an extraction file in this directory recording:
8
+
9
+ 1. The exact paper version/DOI and table, page or supplementary location.
10
+ 2. Population, exclusions, sample size, disease outcome and reference standard.
11
+ 3. Test assay, units, cutoff and positive direction.
12
+ 4. The four counts, their derivation and reconciliation against the source totals. Separate thresholds/cohorts must never be merged into a single ROC series.
13
+ 5. Reviewer, review date and an independent check of all four cells.
14
+
15
+ Link that extraction from `provenance.extraction`, fill the remaining fields, add a regression fixture and update warnings only when the numerical claim is substantiated. No such independent extraction review was performed for the version 5 changes.
16
+
17
+ Simulated ROC points carry `simulated` provenance; editing them changes it to `edited-simulated`, preserving the qualification on AUC. A reference citation cannot convert a simulated point into an observed threshold.