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.
@@ -1,27 +1,40 @@
1
1
  (function (root, factory) {
2
2
  if (typeof module === "object" && module.exports) {
3
- module.exports = factory();
3
+ module.exports = factory(require("./diagcalc-presentation"));
4
4
  return;
5
5
  }
6
6
 
7
- root.DiagcalcCore = factory();
8
- }(typeof globalThis !== "undefined" ? globalThis : this, () => {
9
- function validateInputs({ tp, fp, fn, tn, preTestProb }) {
7
+ root.DiagcalcCore = factory(root.DiagcalcPresentation);
8
+ }(/** @type {any} */ (typeof globalThis !== "undefined" ? globalThis : this), (presentation) => {
9
+ /**
10
+ * @typedef {import("./diagcalc-types").DiagnosticInput} DiagnosticInput
11
+ * @typedef {import("./diagcalc-types").CalculationOptions} CalculationOptions
12
+ */
13
+ /** @param {unknown} input Confusion matrix counts and probability in percent. */
14
+ function validateInputs(input) {
15
+ if (!input || typeof input !== "object") {
16
+ return { valid: false, message: "Enter a confusion matrix and pre-test probability." };
17
+ }
18
+ const { tp, fp, fn, tn, preTestProb } = /** @type {DiagnosticInput} */ (input);
10
19
  const inputs = [tp, fp, fn, tn];
11
20
  if (inputs.some((value) => Number.isNaN(value) || value < 0)) {
12
21
  return { valid: false, message: "Enter non-negative integers for all confusion matrix cells." };
13
22
  }
14
23
 
15
- if (inputs.some((value) => !Number.isInteger(value))) {
24
+ if (inputs.some((value) => !Number.isSafeInteger(value))) {
16
25
  return { valid: false, message: "Use whole numbers for TP, FP, FN, and TN." };
17
26
  }
18
27
 
19
- if (Number.isNaN(preTestProb)) {
28
+ if (!Number.isSafeInteger(tp + fp + fn + tn)) {
29
+ return { valid: false, message: "The total must be a safe whole number (at most 9007199254740991)." };
30
+ }
31
+
32
+ if (!Number.isFinite(preTestProb)) {
20
33
  return { valid: false, message: "Enter a valid pre-test probability." };
21
34
  }
22
35
 
23
- if (preTestProb < 0 || preTestProb >= 100) {
24
- return { valid: false, message: "Pre-test probability must be between 0 and 99.9%." };
36
+ if (preTestProb < 0 || preTestProb > 100) {
37
+ return { valid: false, message: "Pre-test probability must be between 0% and 100%, inclusive." };
25
38
  }
26
39
 
27
40
  if (tp + fn === 0) {
@@ -32,18 +45,15 @@
32
45
  return { valid: false, message: "Specificity is indeterminate: add cases without disease (TN or FP)." };
33
46
  }
34
47
 
35
- if (tp + fp === 0) {
36
- return { valid: false, message: "PPV is indeterminate: at least one positive test result is required." };
37
- }
38
-
39
- if (tn + fn === 0) {
40
- return { valid: false, message: "NPV is indeterminate: at least one negative test result is required." };
41
- }
42
-
43
48
  return { valid: true };
44
49
  }
45
50
 
46
- function calculateMetrics({ tp, fp, fn, tn, preTestProb }) {
51
+ /** @param {DiagnosticInput} input @param {CalculationOptions} [opts] */
52
+ function calculateResults(input, opts) {
53
+ if (!validateInputs(input).valid) return null;
54
+ if (opts && opts.continuityCorrection && !["auto", "always", "never"].includes(opts.continuityCorrection)) return null;
55
+ const { tp, fp, fn, tn, preTestProb } = input;
56
+ const continuityMode = normaliseContinuityMode(opts && opts.continuityCorrection);
47
57
  const totals = {
48
58
  diseased: tp + fn,
49
59
  nonDiseased: tn + fp,
@@ -56,71 +66,83 @@
56
66
  const specificity = tn / totals.nonDiseased;
57
67
  const ppv = tp / totals.positives;
58
68
  const npv = tn / totals.negatives;
59
- const lrPositive = calculateRatio(sensitivity, 1 - specificity);
60
- const lrNegative = calculateRatio(1 - sensitivity, specificity);
69
+ const lrPositive = calculateRatio(sensitivity, fp / totals.nonDiseased);
70
+ const lrNegative = calculateRatio(fn / totals.diseased, specificity);
61
71
  const preTestOdds = calculateOdds(preTest);
62
72
  const postTestOddsPositive = multiplyOdds(preTestOdds, lrPositive);
63
73
  const postTestOddsNegative = multiplyOdds(preTestOdds, lrNegative);
64
74
  const postTestProbPositive = probabilityFromOdds(postTestOddsPositive);
65
75
  const postTestProbNegative = probabilityFromOdds(postTestOddsNegative);
66
76
 
67
- return {
77
+ const lrPositiveCI = Number.isNaN(lrPositive) ? null : calcLogRatioCI(tp, totals.diseased, fp, totals.nonDiseased, { continuityCorrection: continuityMode });
78
+ const lrNegativeCI = Number.isNaN(lrNegative) ? null : calcLogRatioCI(fn, totals.diseased, tn, totals.nonDiseased, { continuityCorrection: continuityMode });
79
+ const postTestPositiveCI = Number.isNaN(postTestProbPositive) ? null : calcPostTestCI(preTest, lrPositiveCI);
80
+ const postTestNegativeCI = Number.isNaN(postTestProbNegative) ? null : calcPostTestCI(preTest, lrNegativeCI);
81
+ const dor = calcDOR(tp, fp, fn, tn, { continuityCorrection: continuityMode });
82
+ const nns = (sensitivity > 0 && preTest > 0) ? 1 / (sensitivity * preTest) : Infinity;
83
+
84
+ const results = {
68
85
  sensitivity: {
69
- label: "Sensitivity",
70
86
  value: sensitivity,
71
87
  ci: calcWilsonInterval(tp, totals.diseased),
72
- note: buildSensitivityNote(sensitivity),
73
88
  },
74
89
  specificity: {
75
- label: "Specificity",
76
90
  value: specificity,
77
91
  ci: calcWilsonInterval(tn, totals.nonDiseased),
78
- note: buildSpecificityNote(specificity),
79
92
  },
80
93
  ppv: {
81
- label: "Positive predictive value (PPV)",
82
94
  value: ppv,
83
95
  ci: calcWilsonInterval(tp, totals.positives),
84
- note: "Probability of disease given a positive result.",
85
96
  },
86
97
  npv: {
87
- label: "Negative predictive value (NPV)",
88
98
  value: npv,
89
99
  ci: calcWilsonInterval(tn, totals.negatives),
90
- note: "Probability of no disease given a negative result.",
100
+ },
101
+ dor: {
102
+ value: dor.value,
103
+ ci: dor.ci,
104
+ },
105
+ numberNeededToScreen: {
106
+ value: nns,
91
107
  },
92
108
  lrPositive: {
93
- label: "Positive likelihood ratio (LR+)",
94
109
  value: lrPositive,
95
- formatter: formatLikelihood,
96
- note: interpretLRPositive(lrPositive),
110
+ ci: lrPositiveCI,
97
111
  },
98
112
  lrNegative: {
99
- label: "Negative likelihood ratio (LR-)",
100
113
  value: lrNegative,
101
- formatter: formatLikelihood,
102
- note: interpretLRNegative(lrNegative),
114
+ ci: lrNegativeCI,
103
115
  },
104
116
  preTestProbability: {
105
- label: "Pre-test probability",
106
117
  value: preTest,
107
- note: "Estimated starting point before the test result.",
108
118
  },
109
119
  postTestPositive: {
110
- label: "Post-test probability (positive result)",
111
120
  value: postTestProbPositive,
112
- note: "Updated probability of disease after a positive test.",
121
+ ci: postTestPositiveCI,
113
122
  },
114
123
  postTestNegative: {
115
- label: "Post-test probability (negative result)",
116
124
  value: postTestProbNegative,
117
- note: "Updated probability of disease after a negative test.",
125
+ ci: postTestNegativeCI,
118
126
  },
119
127
  };
128
+ return Object.fromEntries(Object.entries(results).map(([key, metric]) => [key, {
129
+ ...metric,
130
+ unit: ["dor", "lrPositive", "lrNegative"].includes(key) ? "ratio" : key === "numberNeededToScreen" ? "count" : "probability",
131
+ interpretation: { id: key, params: { value: metric.value } },
132
+ }]));
133
+ }
134
+
135
+ // Compatibility adapter: new integrations may consume calculateResults()
136
+ // directly; all prose and formatter selection live in presentation.
137
+ /** @param {DiagnosticInput} input @param {CalculationOptions} [opts] */
138
+ function calculateMetrics(input, opts = {}) {
139
+ const results = calculateResults(input, opts);
140
+ return results && presentation.presentResults(results, { formatLikelihood, formatNNS }, opts && opts.locale);
120
141
  }
121
142
 
143
+ /** @param {number} successes @param {number} total */
122
144
  function calcWilsonInterval(successes, total) {
123
- if (total === 0) {
145
+ if (!Number.isSafeInteger(total) || !Number.isSafeInteger(successes) || successes < 0 || successes > total || total <= 0) {
124
146
  return null;
125
147
  }
126
148
 
@@ -134,7 +156,439 @@
134
156
  return { lower, upper };
135
157
  }
136
158
 
159
+ // 95% CI for a ratio of two binomial proportions on the log scale.
160
+ // Used for LR+: (x1, n1) = (TP, diseased), (x2, n2) = (FP, non-diseased).
161
+ // Used for LR-: (x1, n1) = (FN, diseased), (x2, n2) = (TN, non-diseased).
162
+ // Simel DL, Samsa GP, Matchar DB. J Clin Epidemiol 1991;44(8):763–770.
163
+ // Continuity correction (+0.5 on each cell) applied when any cell is 0.
164
+ function calcLogRatioCI(x1, n1, x2, n2, opts) {
165
+ if (![x1, n1, x2, n2].every(Number.isSafeInteger) || x1 < 0 || x2 < 0 || n1 <= 0 || n2 <= 0 || x1 > n1 || x2 > n2) return null;
166
+ const mode = normaliseContinuityMode(opts && opts.continuityCorrection);
167
+ let a = x1;
168
+ let b = n1 - x1;
169
+ let c = x2;
170
+ let d = n2 - x2;
171
+ const hasZero = a === 0 || b === 0 || c === 0 || d === 0;
172
+
173
+ if (mode === "always" || (mode === "auto" && hasZero)) {
174
+ a += 0.5;
175
+ b += 0.5;
176
+ c += 0.5;
177
+ d += 0.5;
178
+ }
179
+ // "never" mode: leave cells as-is — the existing checks below catch the
180
+ // cases where the maths actually fails (p1 or p2 = 0).
181
+
182
+ const tn1 = a + b;
183
+ const tn2 = c + d;
184
+ const p1 = a / tn1;
185
+ const p2 = c / tn2;
186
+
187
+ if (p1 === 0 || p2 === 0) {
188
+ return null;
189
+ }
190
+
191
+ const logRatio = Math.log(p1 / p2);
192
+ const seLog = Math.sqrt((1 - p1) / a + (1 - p2) / c);
193
+
194
+ if (!Number.isFinite(seLog) || !Number.isFinite(logRatio)) {
195
+ return null;
196
+ }
197
+
198
+ const z = 1.96;
199
+ return {
200
+ lower: Math.exp(logRatio - z * seLog),
201
+ upper: Math.exp(logRatio + z * seLog),
202
+ };
203
+ }
204
+
205
+ function normaliseContinuityMode(value) {
206
+ if (value === "always" || value === "never") return value;
207
+ return "auto";
208
+ }
209
+
210
+ // Cohen's kappa for inter-rater agreement on a binary outcome.
211
+ // Inputs: the four cells of the rater1×rater2 2×2 table.
212
+ // Returns { value, ci, observed, expected, n, interpretation } or null on
213
+ // empty input. Marginal-adjusted asymptotic variance for the CI; Landis & Koch (1977) bins
214
+ // for the qualitative interpretation.
215
+ function calcCohenKappa(input) {
216
+ if (!input || typeof input !== "object") return null;
217
+ const { bothPos, only1Pos, only2Pos, bothNeg } = input;
218
+ const cells = [bothPos, only1Pos, only2Pos, bothNeg];
219
+ if (cells.some((v) => !Number.isSafeInteger(v) || v < 0)) return null;
220
+ const N = bothPos + only1Pos + only2Pos + bothNeg;
221
+ if (N === 0 || !Number.isSafeInteger(N)) return null;
222
+
223
+ const po = (bothPos + bothNeg) / N;
224
+ const r1Pos = (bothPos + only1Pos) / N;
225
+ const r2Pos = (bothPos + only2Pos) / N;
226
+ const r1Neg = (only2Pos + bothNeg) / N;
227
+ const r2Neg = (only1Pos + bothNeg) / N;
228
+ const pe = r1Pos * r2Pos + r1Neg * r2Neg;
229
+
230
+ if (pe >= 1 - 1e-12) {
231
+ return {
232
+ value: NaN,
233
+ ci: null,
234
+ observed: po,
235
+ expected: pe,
236
+ n: N,
237
+ interpretation: "Expected agreement is 1; kappa is undefined.",
238
+ };
239
+ }
240
+
241
+ const kappa = (po - pe) / (1 - pe);
242
+ // Unweighted asymptotic variance including estimated marginal frequencies.
243
+ // Reference implementation: statsmodels.stats.inter_rater.cohens_kappa.
244
+ const rows = [r1Pos, r1Neg];
245
+ const cols = [r2Pos, r2Neg];
246
+ const diagonal = [bothPos / N, bothNeg / N];
247
+ const termA = diagonal.reduce((sum, p, i) =>
248
+ sum + p * (1 - (rows[i] + cols[i]) * (1 - kappa)) ** 2, 0);
249
+ const termB = (1 - kappa) ** 2 * (
250
+ (only1Pos / N) * (cols[0] + rows[1]) ** 2 +
251
+ (only2Pos / N) * (cols[1] + rows[0]) ** 2
252
+ );
253
+ const termC = (kappa - pe * (1 - kappa)) ** 2;
254
+ const variance = Math.max(0, (termA + termB - termC) / ((1 - pe) ** 2 * N));
255
+ const seKappa = Math.sqrt(variance);
256
+ const z = 1.96;
257
+ return {
258
+ value: kappa,
259
+ ci: { lower: kappa - z * seKappa, upper: kappa + z * seKappa },
260
+ observed: po,
261
+ expected: pe,
262
+ n: N,
263
+ interpretation: interpretKappa(kappa),
264
+ };
265
+ }
266
+
267
+ function interpretKappa(value) {
268
+ return presentation.noteFor("interpretKappa", value);
269
+ }
270
+
271
+ // ROC reconstruction from a list of (cutoff, TP, FP, FN, TN) rows, one per
272
+ // threshold of a continuous test. Returns the sorted points on the (FPR, TPR)
273
+ // plane, trapezoidal AUC including the (0,0) and (1,1) anchors, and the index
274
+ // of the row with the highest Youden's J = sens + spec − 1.
275
+ /** @param {unknown} rows @param {{direction?: string}} [opts] */
276
+ function validateRocRows(rows, opts = {}) {
277
+ const errors = [];
278
+ if (!Array.isArray(rows) || rows.length === 0 || rows.length > 500) {
279
+ return { valid: false, errors: [{ row: null, message: "Use between one and 500 complete cutoff rows." }] };
280
+ }
281
+ let totals = null;
282
+ rows.forEach((row, index) => {
283
+ const validation = validateInputs(row && { ...row, preTestProb: 50 });
284
+ if (!validation.valid) {
285
+ errors.push({ row: index + 1, message: validation.message });
286
+ return;
287
+ }
288
+ const current = [row.tp + row.fn, row.fp + row.tn];
289
+ if (totals && (totals[0] !== current[0] || totals[1] !== current[1])) {
290
+ errors.push({ row: index + 1, message: "All cutoffs must use the same diseased and non-diseased cohorts." });
291
+ }
292
+ totals = totals || current;
293
+ if (row.cutoff !== null && row.cutoff !== undefined && !Number.isFinite(row.cutoff)) {
294
+ errors.push({ row: index + 1, message: "Cutoff must be a finite number or left blank." });
295
+ }
296
+ });
297
+ if (errors.length) return { valid: false, errors };
298
+ const sorted = rows.map((row, index) => ({ ...row, index }))
299
+ .sort((a, b) => a.fp - b.fp || a.tp - b.tp);
300
+ for (let i = 1; i < sorted.length; i += 1) {
301
+ if (sorted[i].tp < sorted[i - 1].tp) {
302
+ errors.push({ row: sorted[i].index + 1, message: "Sensitivity must not decrease as the false-positive rate increases." });
303
+ }
304
+ }
305
+ const direction = opts.direction === "lower" ? "lower" : "higher";
306
+ const cutoffs = sorted.filter((row) => Number.isFinite(row.cutoff));
307
+ for (let i = 1; i < cutoffs.length; i += 1) {
308
+ const previous = cutoffs[i - 1];
309
+ const current = cutoffs[i];
310
+ const changed = current.tp !== previous.tp || current.fp !== previous.fp;
311
+ const ordered = direction === "higher" ? current.cutoff < previous.cutoff : current.cutoff > previous.cutoff;
312
+ if (changed && !ordered) {
313
+ errors.push({ row: current.index + 1, message: `Cutoffs must follow the selected ${direction}-score-positive direction.` });
314
+ }
315
+ }
316
+ return { valid: errors.length === 0, errors };
317
+ }
318
+
319
+ function calculateROC(rows, opts) {
320
+ if (!validateRocRows(rows, opts).valid) return null;
321
+ const points = rows.map((row) => {
322
+ const sens = row.tp / (row.tp + row.fn);
323
+ const spec = row.tn / (row.tn + row.fp);
324
+ return {
325
+ ...row,
326
+ cutoff: row.cutoff ?? null,
327
+ provenance: row.provenance || (row.synthetic ? "simulated" : "observed"),
328
+ sens,
329
+ spec,
330
+ fpr: row.fp / (row.tn + row.fp),
331
+ youden: sens + spec - 1,
332
+ };
333
+ }).sort((a, b) => a.fpr - b.fpr || a.sens - b.sens);
334
+
335
+ const augmented = [
336
+ { fpr: 0, sens: 0 },
337
+ ...points,
338
+ { fpr: 1, sens: 1 },
339
+ ];
340
+ let auc = 0;
341
+ for (let i = 1; i < augmented.length; i += 1) {
342
+ const dx = augmented[i].fpr - augmented[i - 1].fpr;
343
+ if (dx <= 0) continue;
344
+ auc += dx * (augmented[i].sens + augmented[i - 1].sens) / 2;
345
+ }
346
+
347
+ let optimalIndex = -1;
348
+ let maxYouden = -Infinity;
349
+ for (let i = 0; i < points.length; i += 1) {
350
+ if (points[i].youden > maxYouden) {
351
+ maxYouden = points[i].youden;
352
+ optimalIndex = i;
353
+ }
354
+ }
355
+
356
+ return {
357
+ points,
358
+ simulated: points.some((point) => point.provenance !== "observed"),
359
+ auc,
360
+ optimalIndex,
361
+ optimalPoint: optimalIndex >= 0 ? points[optimalIndex] : null,
362
+ };
363
+ }
364
+
365
+ // Standard normal CDF Φ(x). Abramowitz & Stegun 7.1.26 polynomial approximation
366
+ // of erf; max error ~1.5e-7 — fine for ROC scaffolding.
367
+ /** @param {number} x */
368
+ function stdNormalCdf(x) {
369
+ if (Number.isNaN(x)) return NaN;
370
+ if (!Number.isFinite(x)) return x > 0 ? 1 : 0;
371
+ const sign = x < 0 ? -1 : 1;
372
+ const ax = Math.abs(x) / Math.SQRT2;
373
+ const t = 1 / (1 + 0.3275911 * ax);
374
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
375
+ return 0.5 * (1 + sign * y);
376
+ }
377
+
378
+ // Inverse standard normal Φ⁻¹(p) via Acklam's rational approximation.
379
+ // Max relative error ~1.15e-9 in (0, 1).
380
+ /** @param {number} p */
381
+ function invStdNormal(p) {
382
+ if (!(p > 0 && p < 1)) {
383
+ if (p === 0) return -Infinity;
384
+ if (p === 1) return Infinity;
385
+ return NaN;
386
+ }
387
+ const a = [-3.969683028665376e+1, 2.209460984245205e+2, -2.759285104469687e+2, 1.383577518672690e+2, -3.066479806614716e+1, 2.506628277459239e+0];
388
+ const b = [-5.447609879822406e+1, 1.615858368580409e+2, -1.556989798598866e+2, 6.680131188771972e+1, -1.328068155288572e+1];
389
+ const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838e+0, -2.549732539343734e+0, 4.374664141464968e+0, 2.938163982698783e+0];
390
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996e+0, 3.754408661907416e+0];
391
+ const pLow = 0.02425;
392
+ const pHigh = 1 - pLow;
393
+ let q, r;
394
+ if (p < pLow) {
395
+ q = Math.sqrt(-2 * Math.log(p));
396
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
397
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
398
+ }
399
+ if (p <= pHigh) {
400
+ q = p - 0.5;
401
+ r = q * q;
402
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
403
+ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
404
+ }
405
+ q = Math.sqrt(-2 * Math.log(1 - p));
406
+ return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
407
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
408
+ }
409
+
410
+ // Synthesize a scaffold of ROC cutoffs from a single observed 2×2.
411
+ // Assumes a binormal (equal-variance) score model: scores in non-diseased
412
+ // ~ N(0,1), in diseased ~ N(d,1). The observed sens/spec pin down d via
413
+ // d = Φ⁻¹(sens) + Φ⁻¹(spec). Then we sweep `count` cutoffs at evenly spaced
414
+ // FPRs in (0, 1), round the implied counts to the observed P/N totals.
415
+ // Returns an array of { cutoff, tp, fp, fn, tn, synthetic: true } — the
416
+ // cutoff is the implied normalized score (Φ⁻¹(1-FPR)) rounded to 2 dp.
417
+ function generateSyntheticRocPoints(observed, count) {
418
+ if (!validateInputs(observed && { ...observed, preTestProb: 50 }).valid) return [];
419
+ const { tp, fp, fn, tn } = observed;
420
+ if (!Number.isInteger(tp) || !Number.isInteger(fp) || !Number.isInteger(fn) || !Number.isInteger(tn)) return [];
421
+ const P = tp + fn;
422
+ const N = tn + fp;
423
+ if (P <= 0 || N <= 0) return [];
424
+ const sens = tp / P;
425
+ const spec = tn / N;
426
+ // Edge: a perfect (or pathological) observed point has Φ⁻¹(0) = -∞.
427
+ // Nudge sens/spec away from 0 and 1 so d stays finite.
428
+ const eps = 1 / (2 * Math.max(P, N));
429
+ const sClamp = Math.min(1 - eps, Math.max(eps, sens));
430
+ const pClamp = Math.min(1 - eps, Math.max(eps, spec));
431
+ const d = invStdNormal(sClamp) + invStdNormal(pClamp);
432
+ const n = Math.max(2, Math.min(20, Number.isInteger(count) ? count : 7));
433
+ // Span (edge, 1 − edge) inclusively so the scaffolded points reach the
434
+ // bottom-left and top-right of the ROC plot. Previous fence-post sampling
435
+ // (i / (n + 1)) topped out around FPR 0.83 and bottomed out around 0.17,
436
+ // leaving the corners empty.
437
+ const edge = 0.025;
438
+ const rows = [];
439
+ for (let i = 0; i < n; i += 1) {
440
+ const fpr = n === 1 ? 0.5 : edge + (1 - 2 * edge) * i / (n - 1);
441
+ const c = invStdNormal(1 - fpr);
442
+ const tprImplied = 1 - stdNormalCdf(c - d);
443
+ const tpI = Math.max(0, Math.min(P, Math.round(tprImplied * P)));
444
+ const fpI = Math.max(0, Math.min(N, Math.round(fpr * N)));
445
+ rows.push({
446
+ cutoff: Number(c.toFixed(2)),
447
+ tp: tpI,
448
+ fp: fpI,
449
+ fn: P - tpI,
450
+ tn: N - fpI,
451
+ synthetic: true,
452
+ provenance: "simulated",
453
+ });
454
+ }
455
+ return rows;
456
+ }
457
+
458
+ // Diagnostic odds ratio with log-normal CI.
459
+ // DOR = (TP·TN) / (FP·FN); SE(log DOR) = sqrt(1/TP + 1/FP + 1/FN + 1/TN).
460
+ // Continuity correction (+0.5 to every cell) if any cell is 0.
461
+ function calcDOR(tp, fp, fn, tn, opts) {
462
+ if (![tp, fp, fn, tn].every((value) => Number.isSafeInteger(value) && value >= 0) || !Number.isSafeInteger(tp + fp + fn + tn) || tp + fp + fn + tn === 0) return { value: NaN, ci: null };
463
+ const mode = normaliseContinuityMode(opts && opts.continuityCorrection);
464
+ let a = tp;
465
+ let b = fp;
466
+ let c = fn;
467
+ let d = tn;
468
+ const hasZero = a === 0 || b === 0 || c === 0 || d === 0;
469
+ if (mode === "always" || (mode === "auto" && hasZero)) {
470
+ a += 0.5;
471
+ b += 0.5;
472
+ c += 0.5;
473
+ d += 0.5;
474
+ }
475
+ // never + hasZero → DOR is 0 or ∞ depending on which cell is zero; the CI is undefined.
476
+ const rawValue = mode === "never" ? (tp * tn) / (fp * fn) : (a * d) / (b * c);
477
+ if (!Number.isFinite(rawValue) || rawValue <= 0) {
478
+ return { value: rawValue, ci: null };
479
+ }
480
+ const seLog = Math.sqrt(1 / a + 1 / b + 1 / c + 1 / d);
481
+ const logDor = Math.log(rawValue);
482
+ const z = 1.96;
483
+ return {
484
+ value: rawValue,
485
+ ci: {
486
+ lower: Math.exp(logDor - z * seLog),
487
+ upper: Math.exp(logDor + z * seLog),
488
+ },
489
+ };
490
+ }
491
+
492
+ function interpretDOR(value) {
493
+ return presentation.noteFor("interpretDOR", value);
494
+ }
495
+
496
+ // Surface common biases that the 2x2 alone can signal.
497
+ // Returns an array of plain-text warnings; empty array if nothing flagged.
498
+ /** @param {DiagnosticInput} input */
499
+ function buildBiasWarnings({ tp, fp, fn, tn, preTestProb }) {
500
+ const warnings = [];
501
+ const nDiseased = tp + fn;
502
+ const nNonDiseased = tn + fp;
503
+ const total = nDiseased + nNonDiseased;
504
+
505
+ if (nDiseased > 0 && nDiseased < 30) {
506
+ warnings.push(`Small diseased group (n=${nDiseased}). Sensitivity and likelihood-ratio estimates are imprecise — interpret 95% CIs carefully.`);
507
+ }
508
+ if (nNonDiseased > 0 && nNonDiseased < 30) {
509
+ warnings.push(`Small non-diseased group (n=${nNonDiseased}). Specificity and likelihood-ratio estimates are imprecise — interpret 95% CIs carefully.`);
510
+ }
511
+
512
+ if (total > 0 && Number.isFinite(preTestProb)) {
513
+ const studyPrev = nDiseased / total;
514
+ const userPrev = preTestProb / 100;
515
+ if (Math.abs(userPrev - studyPrev) > 0.20) {
516
+ warnings.push(
517
+ `Study prevalence (${(studyPrev * 100).toFixed(1)}%) differs substantially from your pre-test probability (${preTestProb.toFixed(1)}%). The PPV and NPV cards reflect the 2x2's prevalence; use the post-test probabilities for your patient.`
518
+ );
519
+ }
520
+ }
521
+
522
+ return warnings;
523
+ }
524
+
525
+ // Pauker DG, Kassirer JP. The threshold approach to clinical decision making.
526
+ // N Engl J Med 1980;302(20):1109–1117.
527
+ //
528
+ // Given a clinician-stated treatment threshold Pt (the post-test probability
529
+ // at which the expected utility of treating equals that of not treating) and
530
+ // the diagnostic test's LR+ and LR−, return the two pre-test thresholds that
531
+ // bracket the "testing is useful" zone:
532
+ //
533
+ // testingThreshold (P_low): below this pre-test, even a positive
534
+ // result keeps post-test < Pt → don't test
535
+ // testTreatmentThreshold (P_high): above this pre-test, even a negative
536
+ // result keeps post-test ≥ Pt → just treat
537
+ //
538
+ // Derivation: solving post-odds = pre-odds × LR for the pre-test probability
539
+ // that maps to Pt on the post-test scale gives
540
+ // P = Pt / (Pt + (1 − Pt) × LR)
541
+ /** @param {{treatmentThreshold: number, lrPositive: number, lrNegative: number}} opts */
542
+ function calculateThresholds(opts) {
543
+ if (!opts) return null;
544
+ const Pt = opts.treatmentThreshold;
545
+ if (!Number.isFinite(Pt) || Pt <= 0 || Pt >= 1) {
546
+ return null;
547
+ }
548
+
549
+ if (!(opts.lrPositive > 1) || !(opts.lrNegative >= 0 && opts.lrNegative < 1)) return null;
550
+
551
+ function thresholdFromLR(lr) {
552
+ if (Number.isNaN(lr)) return null;
553
+ if (lr === Infinity) return 0;
554
+ if (lr === 0) return 1;
555
+ if (!Number.isFinite(lr) || lr < 0) return null;
556
+ return Pt / (Pt + (1 - Pt) * lr);
557
+ }
558
+
559
+ return {
560
+ treatmentThreshold: Pt,
561
+ testingThreshold: thresholdFromLR(opts.lrPositive),
562
+ testTreatmentThreshold: thresholdFromLR(opts.lrNegative),
563
+ };
564
+ }
565
+
566
+ // Propagate the LR CI to the post-test probability scale via the Bayes' update
567
+ // Monotonic Bayes transformation preserves endpoints; the prior is fixed.
568
+ function calcPostTestCI(preTestProbability, lrCi) {
569
+ if (!lrCi) {
570
+ return null;
571
+ }
572
+ if (!Number.isFinite(preTestProbability) || preTestProbability < 0 || preTestProbability > 1) {
573
+ return null;
574
+ }
575
+
576
+ const preOdds = calculateOdds(preTestProbability);
577
+ const lower = probabilityFromOdds(multiplyOdds(preOdds, lrCi.lower));
578
+ const upper = probabilityFromOdds(multiplyOdds(preOdds, lrCi.upper));
579
+
580
+ if (Number.isNaN(lower) || Number.isNaN(upper)) {
581
+ return null;
582
+ }
583
+ return { lower, upper };
584
+ }
585
+
586
+ /** @param {number} numerator @param {number} denominator */
137
587
  function calculateRatio(numerator, denominator) {
588
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || numerator < 0 || denominator < 0) return NaN;
589
+ if (numerator === 0 && denominator === 0) {
590
+ return NaN;
591
+ }
138
592
  if (denominator === 0) {
139
593
  return Infinity;
140
594
  }
@@ -144,7 +598,9 @@
144
598
  return numerator / denominator;
145
599
  }
146
600
 
601
+ /** @param {number} probability */
147
602
  function calculateOdds(probability) {
603
+ if (!Number.isFinite(probability) || probability < 0 || probability > 1) return NaN;
148
604
  if (probability === 1) {
149
605
  return Infinity;
150
606
  }
@@ -154,17 +610,21 @@
154
610
  return probability / (1 - probability);
155
611
  }
156
612
 
613
+ /** @param {number} odds @param {number} ratio */
157
614
  function multiplyOdds(odds, ratio) {
158
- if (odds === 0 || ratio === 0) {
159
- return 0;
160
- }
161
- if (!Number.isFinite(odds) || !Number.isFinite(ratio)) {
162
- return Infinity;
163
- }
615
+ if (typeof odds !== "number" || typeof ratio !== "number" || Number.isNaN(odds) || Number.isNaN(ratio) || odds < 0 || ratio < 0) return NaN;
616
+ if ((odds === 0 && ratio === Infinity) || (ratio === 0 && odds === Infinity)) return NaN;
164
617
  return odds * ratio;
165
618
  }
166
619
 
620
+ /** @param {number} probability */
621
+ function chainedPreTestProbability(probability) {
622
+ return Number.isFinite(probability) && probability >= 0 && probability <= 1 ? probability * 100 : NaN;
623
+ }
624
+
625
+ /** @param {number} odds */
167
626
  function probabilityFromOdds(odds) {
627
+ if (typeof odds !== "number" || Number.isNaN(odds) || odds < 0) return NaN;
168
628
  if (odds === Infinity) {
169
629
  return 1;
170
630
  }
@@ -181,6 +641,7 @@
181
641
  return formatPercentage(value);
182
642
  }
183
643
 
644
+ /** @param {number} value */
184
645
  function formatPercentage(value) {
185
646
  if (!Number.isFinite(value)) {
186
647
  return "—";
@@ -188,6 +649,7 @@
188
649
  return `${(value * 100).toFixed(1)}%`;
189
650
  }
190
651
 
652
+ /** @param {unknown} value */
191
653
  function normaliseDecimal(value) {
192
654
  if (typeof value !== "string") {
193
655
  return "";
@@ -195,6 +657,7 @@
195
657
  return value.replace(",", ".").trim();
196
658
  }
197
659
 
660
+ /** @param {unknown} value */
198
661
  function safeParseInt(value) {
199
662
  if (typeof value !== "string") {
200
663
  return NaN;
@@ -206,73 +669,60 @@
206
669
  if (!/^\d+$/.test(trimmed)) {
207
670
  return NaN;
208
671
  }
209
- return parseInt(trimmed, 10);
672
+ const parsed = Number(trimmed);
673
+ return Number.isSafeInteger(parsed) ? parsed : NaN;
674
+ }
675
+
676
+ /** @param {unknown} value */
677
+ function parseProbability(value) {
678
+ const normalised = normaliseDecimal(value);
679
+ if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalised)) return NaN;
680
+ const parsed = Number(normalised);
681
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : NaN;
210
682
  }
211
683
 
684
+ /** @param {number} value */
212
685
  function formatLikelihood(value) {
213
- if (!Number.isFinite(value)) {
686
+ if (value === Infinity) {
214
687
  return "∞";
215
688
  }
689
+ if (!Number.isFinite(value)) return "—";
216
690
  if (value === 0) {
217
691
  return "0";
218
692
  }
219
693
  return value >= 10 ? value.toFixed(1) : value.toFixed(2);
220
694
  }
221
695
 
696
+ /** @param {number} value */
697
+ function formatNNS(value) {
698
+ if (!Number.isFinite(value)) return "—";
699
+ if (value <= 0) return "—";
700
+ return String(Math.ceil(value));
701
+ }
702
+
703
+ function interpretNNS(value) {
704
+ return presentation.noteFor("interpretNNS", value);
705
+ }
706
+
707
+ /** @param {number} value @param {number} min @param {number} max */
222
708
  function clamp(value, min, max) {
223
709
  return Math.min(Math.max(value, min), max);
224
710
  }
225
711
 
226
712
  function buildSensitivityNote(value) {
227
- if (value >= 0.9) {
228
- return "Captures most cases with disease. Useful for screening.";
229
- }
230
- if (value >= 0.7) {
231
- return "Moderate sensitivity: consider alongside clinical context.";
232
- }
233
- return "Low sensitivity: consider additional tests to reduce false negatives.";
713
+ return presentation.noteFor("buildSensitivityNote", value);
234
714
  }
235
715
 
236
716
  function buildSpecificityNote(value) {
237
- if (value >= 0.9) {
238
- return "Few false positives. Suitable for confirming diagnoses.";
239
- }
240
- if (value >= 0.7) {
241
- return "Moderate specificity: confirm with other laboratory or clinical data.";
242
- }
243
- return "Low specificity: beware of false positives and their impact on treatment decisions.";
717
+ return presentation.noteFor("buildSpecificityNote", value);
244
718
  }
245
719
 
246
720
  function interpretLRPositive(value) {
247
- if (!Number.isFinite(value)) {
248
- return "LR+ is very high: a positive result virtually confirms the disease.";
249
- }
250
- if (value >= 10) {
251
- return "LR+ >= 10 indicates strong evidence in favour of disease.";
252
- }
253
- if (value >= 5) {
254
- return "Moderate LR+: substantially increases the probability of disease.";
255
- }
256
- if (value >= 2) {
257
- return "Low LR+: limited gain; combine with other data.";
258
- }
259
- return "LR+ close to 1: a positive result barely changes the probability of disease.";
721
+ return presentation.noteFor("interpretLRPositive", value);
260
722
  }
261
723
 
262
724
  function interpretLRNegative(value) {
263
- if (!Number.isFinite(value)) {
264
- return "LR- is infinite: a negative result does not reduce the probability of disease.";
265
- }
266
- if (value <= 0.1) {
267
- return "LR- <= 0.1 indicates strong evidence against disease.";
268
- }
269
- if (value <= 0.2) {
270
- return "Moderate LR-: reduces the probability of disease.";
271
- }
272
- if (value <= 0.5) {
273
- return "Low LR-: limited impact; consider further evaluation.";
274
- }
275
- return "LR- close to 1: a negative result does not rule out disease.";
725
+ return presentation.noteFor("interpretLRNegative", value);
276
726
  }
277
727
 
278
728
  function buildProbabilityBar(value, width) {
@@ -283,23 +733,41 @@
283
733
  }
284
734
 
285
735
  return {
736
+ buildBiasWarnings,
286
737
  buildProbabilityBar,
287
738
  buildSensitivityNote,
288
739
  buildSpecificityNote,
740
+ calcCohenKappa,
741
+ calcDOR,
742
+ calcLogRatioCI,
743
+ calcPostTestCI,
289
744
  calcWilsonInterval,
290
745
  calculateMetrics,
746
+ calculateResults,
747
+ chainedPreTestProbability,
291
748
  calculateOdds,
749
+ calculateROC,
292
750
  calculateRatio,
751
+ calculateThresholds,
293
752
  clamp,
294
753
  formatLikelihood,
754
+ formatNNS,
295
755
  formatPercentage,
296
756
  formatValue,
757
+ generateSyntheticRocPoints,
758
+ interpretDOR,
759
+ interpretKappa,
297
760
  interpretLRNegative,
298
761
  interpretLRPositive,
762
+ interpretNNS,
763
+ invStdNormal,
299
764
  multiplyOdds,
300
765
  normaliseDecimal,
301
766
  probabilityFromOdds,
302
767
  safeParseInt,
768
+ stdNormalCdf,
303
769
  validateInputs,
770
+ validateRocRows,
771
+ parseProbability,
304
772
  };
305
773
  }));