diagcalc 3.2.4 → 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 at least 0% and below 100%." };
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,436 @@
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;
138
589
  if (numerator === 0 && denominator === 0) {
139
590
  return NaN;
140
591
  }
@@ -147,7 +598,9 @@
147
598
  return numerator / denominator;
148
599
  }
149
600
 
601
+ /** @param {number} probability */
150
602
  function calculateOdds(probability) {
603
+ if (!Number.isFinite(probability) || probability < 0 || probability > 1) return NaN;
151
604
  if (probability === 1) {
152
605
  return Infinity;
153
606
  }
@@ -157,17 +610,21 @@
157
610
  return probability / (1 - probability);
158
611
  }
159
612
 
613
+ /** @param {number} odds @param {number} ratio */
160
614
  function multiplyOdds(odds, ratio) {
161
- if (odds === 0 || ratio === 0) {
162
- return 0;
163
- }
164
- if (!Number.isFinite(odds) || !Number.isFinite(ratio)) {
165
- return Infinity;
166
- }
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;
167
617
  return odds * ratio;
168
618
  }
169
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 */
170
626
  function probabilityFromOdds(odds) {
627
+ if (typeof odds !== "number" || Number.isNaN(odds) || odds < 0) return NaN;
171
628
  if (odds === Infinity) {
172
629
  return 1;
173
630
  }
@@ -184,6 +641,7 @@
184
641
  return formatPercentage(value);
185
642
  }
186
643
 
644
+ /** @param {number} value */
187
645
  function formatPercentage(value) {
188
646
  if (!Number.isFinite(value)) {
189
647
  return "—";
@@ -191,6 +649,7 @@
191
649
  return `${(value * 100).toFixed(1)}%`;
192
650
  }
193
651
 
652
+ /** @param {unknown} value */
194
653
  function normaliseDecimal(value) {
195
654
  if (typeof value !== "string") {
196
655
  return "";
@@ -198,6 +657,7 @@
198
657
  return value.replace(",", ".").trim();
199
658
  }
200
659
 
660
+ /** @param {unknown} value */
201
661
  function safeParseInt(value) {
202
662
  if (typeof value !== "string") {
203
663
  return NaN;
@@ -209,73 +669,60 @@
209
669
  if (!/^\d+$/.test(trimmed)) {
210
670
  return NaN;
211
671
  }
212
- 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;
213
682
  }
214
683
 
684
+ /** @param {number} value */
215
685
  function formatLikelihood(value) {
216
- if (!Number.isFinite(value)) {
686
+ if (value === Infinity) {
217
687
  return "∞";
218
688
  }
689
+ if (!Number.isFinite(value)) return "—";
219
690
  if (value === 0) {
220
691
  return "0";
221
692
  }
222
693
  return value >= 10 ? value.toFixed(1) : value.toFixed(2);
223
694
  }
224
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 */
225
708
  function clamp(value, min, max) {
226
709
  return Math.min(Math.max(value, min), max);
227
710
  }
228
711
 
229
712
  function buildSensitivityNote(value) {
230
- if (value >= 0.9) {
231
- return "Captures most cases with disease. Useful for screening.";
232
- }
233
- if (value >= 0.7) {
234
- return "Moderate sensitivity: consider alongside clinical context.";
235
- }
236
- return "Low sensitivity: consider additional tests to reduce false negatives.";
713
+ return presentation.noteFor("buildSensitivityNote", value);
237
714
  }
238
715
 
239
716
  function buildSpecificityNote(value) {
240
- if (value >= 0.9) {
241
- return "Few false positives. Suitable for confirming diagnoses.";
242
- }
243
- if (value >= 0.7) {
244
- return "Moderate specificity: confirm with other laboratory or clinical data.";
245
- }
246
- return "Low specificity: beware of false positives and their impact on treatment decisions.";
717
+ return presentation.noteFor("buildSpecificityNote", value);
247
718
  }
248
719
 
249
720
  function interpretLRPositive(value) {
250
- if (!Number.isFinite(value)) {
251
- return "LR+ is very high: a positive result virtually confirms the disease.";
252
- }
253
- if (value >= 10) {
254
- return "LR+ >= 10 indicates strong evidence in favour of disease.";
255
- }
256
- if (value >= 5) {
257
- return "Moderate LR+: substantially increases the probability of disease.";
258
- }
259
- if (value >= 2) {
260
- return "Low LR+: limited gain; combine with other data.";
261
- }
262
- return "LR+ close to 1: a positive result barely changes the probability of disease.";
721
+ return presentation.noteFor("interpretLRPositive", value);
263
722
  }
264
723
 
265
724
  function interpretLRNegative(value) {
266
- if (!Number.isFinite(value)) {
267
- return "LR- is infinite: a negative result does not reduce the probability of disease.";
268
- }
269
- if (value <= 0.1) {
270
- return "LR- <= 0.1 indicates strong evidence against disease.";
271
- }
272
- if (value <= 0.2) {
273
- return "Moderate LR-: reduces the probability of disease.";
274
- }
275
- if (value <= 0.5) {
276
- return "Low LR-: limited impact; consider further evaluation.";
277
- }
278
- return "LR- close to 1: a negative result does not rule out disease.";
725
+ return presentation.noteFor("interpretLRNegative", value);
279
726
  }
280
727
 
281
728
  function buildProbabilityBar(value, width) {
@@ -286,23 +733,41 @@
286
733
  }
287
734
 
288
735
  return {
736
+ buildBiasWarnings,
289
737
  buildProbabilityBar,
290
738
  buildSensitivityNote,
291
739
  buildSpecificityNote,
740
+ calcCohenKappa,
741
+ calcDOR,
742
+ calcLogRatioCI,
743
+ calcPostTestCI,
292
744
  calcWilsonInterval,
293
745
  calculateMetrics,
746
+ calculateResults,
747
+ chainedPreTestProbability,
294
748
  calculateOdds,
749
+ calculateROC,
295
750
  calculateRatio,
751
+ calculateThresholds,
296
752
  clamp,
297
753
  formatLikelihood,
754
+ formatNNS,
298
755
  formatPercentage,
299
756
  formatValue,
757
+ generateSyntheticRocPoints,
758
+ interpretDOR,
759
+ interpretKappa,
300
760
  interpretLRNegative,
301
761
  interpretLRPositive,
762
+ interpretNNS,
763
+ invStdNormal,
302
764
  multiplyOdds,
303
765
  normaliseDecimal,
304
766
  probabilityFromOdds,
305
767
  safeParseInt,
768
+ stdNormalCdf,
306
769
  validateInputs,
770
+ validateRocRows,
771
+ parseProbability,
307
772
  };
308
773
  }));