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.
package/tui/index.js CHANGED
@@ -4,130 +4,9 @@ const blessed = require("blessed");
4
4
  const core = require("../lib/diagcalc-core");
5
5
  const datasetStore = require("../lib/diagcalc-datasets");
6
6
 
7
- function renderResults(metrics) {
8
- const performance = [
9
- renderMetricLine(metrics.sensitivity, "sensitivity"),
10
- renderMetricLine(metrics.specificity, "specificity"),
11
- renderMetricLine(metrics.ppv, "ppv"),
12
- renderMetricLine(metrics.npv, "npv"),
13
- ];
14
-
15
- const bayes = [
16
- renderMetricLine(metrics.lrPositive, "lrPositive"),
17
- renderMetricLine(metrics.lrNegative, "lrNegative"),
18
- renderMetricLine(metrics.preTestProbability, "neutral"),
19
- renderMetricLine(metrics.postTestPositive, "postPositive"),
20
- renderMetricLine(metrics.postTestNegative, "postNegative"),
21
- ];
22
-
23
- const bars = [
24
- `Pre-test [${core.buildProbabilityBar(metrics.preTestProbability.value, 24)}] ${core.formatPercentage(metrics.preTestProbability.value)}`,
25
- `Post-test (+) [${core.buildProbabilityBar(metrics.postTestPositive.value, 24)}] ${core.formatPercentage(metrics.postTestPositive.value)}`,
26
- `Post-test (-) [${core.buildProbabilityBar(metrics.postTestNegative.value, 24)}] ${core.formatPercentage(metrics.postTestNegative.value)}`,
27
- ];
28
-
29
- return [
30
- "{bold}Performance Measures{/bold}",
31
- performance.join("\n"),
32
- "",
33
- "{bold}Bayesian Update{/bold}",
34
- bayes.join("\n"),
35
- "",
36
- "{bold}Probability Shift{/bold}",
37
- bars.join("\n"),
38
- "",
39
- "{bold}Interpretation{/bold}",
40
- `Sensitivity ${metrics.sensitivity.note}`,
41
- `Specificity ${metrics.specificity.note}`,
42
- `LR+ ${metrics.lrPositive.note}`,
43
- `LR- ${metrics.lrNegative.note}`,
44
- ].join("\n");
45
- }
46
-
47
- function renderMetricLine(metric, kind) {
48
- const value = core.formatValue(metric.value, metric.formatter);
49
- const valueTag = colourValue(value, kind, metric.value);
50
- const confidence = renderConfidence(metric);
51
- const confidenceText = confidence ? ` {gray-fg}${confidence}{/}` : "";
52
- return `${metric.label.padEnd(36)} ${valueTag}${confidenceText}`;
53
- }
54
-
55
- function renderConfidence(metric) {
56
- if (!metric.ci) {
57
- return "";
58
- }
59
-
60
- return `95% CI ${core.formatPercentage(metric.ci.lower)} to ${core.formatPercentage(metric.ci.upper)}`;
61
- }
62
-
63
- function colourValue(value, kind, numericValue) {
64
- const { colour, badge } = metricTone(kind, numericValue);
65
- return `{${colour}-fg}${value}{/} {bold}{${colour}-fg}[${badge}]{/}{/}`;
66
- }
67
-
68
- function metricTone(kind, value) {
69
- if (!Number.isFinite(value)) {
70
- return { colour: "magenta", badge: "EXTREME" };
71
- }
72
-
73
- if (kind === "sensitivity" || kind === "specificity" || kind === "ppv" || kind === "npv") {
74
- if (value >= 0.9) {
75
- return { colour: "green", badge: "STRONG" };
76
- }
77
- if (value >= 0.7) {
78
- return { colour: "yellow", badge: "MOD" };
79
- }
80
- return { colour: "red", badge: "WEAK" };
81
- }
82
-
83
- if (kind === "lrPositive") {
84
- if (value >= 10) {
85
- return { colour: "green", badge: "RULE-IN" };
86
- }
87
- if (value >= 5) {
88
- return { colour: "yellow", badge: "USEFUL" };
89
- }
90
- if (value >= 2) {
91
- return { colour: "yellow", badge: "LIMITED" };
92
- }
93
- return { colour: "red", badge: "WEAK" };
94
- }
95
-
96
- if (kind === "lrNegative") {
97
- if (value <= 0.1) {
98
- return { colour: "green", badge: "RULE-OUT" };
99
- }
100
- if (value <= 0.2) {
101
- return { colour: "yellow", badge: "USEFUL" };
102
- }
103
- if (value <= 0.5) {
104
- return { colour: "yellow", badge: "LIMITED" };
105
- }
106
- return { colour: "red", badge: "WEAK" };
107
- }
108
-
109
- if (kind === "postPositive") {
110
- if (value >= 0.8) {
111
- return { colour: "green", badge: "HIGH" };
112
- }
113
- if (value >= 0.4) {
114
- return { colour: "yellow", badge: "MID" };
115
- }
116
- return { colour: "red", badge: "LOW" };
117
- }
118
-
119
- if (kind === "postNegative") {
120
- if (value <= 0.1) {
121
- return { colour: "green", badge: "LOW" };
122
- }
123
- if (value <= 0.3) {
124
- return { colour: "yellow", badge: "MID" };
125
- }
126
- return { colour: "red", badge: "HIGH" };
127
- }
128
-
129
- return { colour: "cyan", badge: "INFO" };
130
- }
7
+ const { renderResults, renderConfidence, CREW } = require("./presentation");
8
+ const { sanitiseRawInput, isAcceptedCharacter } = require("./input");
9
+ const { buildTextExport, buildMarkdownExport } = require("./report");
131
10
 
132
11
  function runTui() {
133
12
  const datasets = [
@@ -187,7 +66,7 @@ function runTui() {
187
66
  height: 3,
188
67
  tags: true,
189
68
  border: "line",
190
- content: "{bold}DIAGCALC{/bold} Arrows move Enter edit/load n new ad hoc case x export txt m export md Tab/Ctrl-N next panel Shift-Tab/Ctrl-P previous r reset q quit",
69
+ content: "{bold}DIAGCALC{/bold} Arrows move Enter edit/load n new + chain x export txt m export md Tab/Ctrl-N next panel r reset q quit",
191
70
  });
192
71
 
193
72
  const datasetList = blessed.list({
@@ -203,14 +82,14 @@ function runTui() {
203
82
  mouse: true,
204
83
  style: {
205
84
  selected: {
206
- bg: "blue",
85
+ bg: CREW.queequeg,
207
86
  },
208
87
  border: {
209
- fg: "white",
88
+ fg: CREW.ishmael,
210
89
  },
211
90
  focus: {
212
91
  border: {
213
- fg: "blue",
92
+ fg: CREW.queequeg,
214
93
  },
215
94
  },
216
95
  },
@@ -245,11 +124,11 @@ function runTui() {
245
124
  tags: true,
246
125
  style: {
247
126
  border: {
248
- fg: "white",
127
+ fg: CREW.ishmael,
249
128
  },
250
129
  focus: {
251
130
  border: {
252
- fg: "blue",
131
+ fg: CREW.queequeg,
253
132
  },
254
133
  },
255
134
  },
@@ -272,11 +151,11 @@ function runTui() {
272
151
  content: "Press Enter on a dataset or input row.",
273
152
  style: {
274
153
  border: {
275
- fg: "white",
154
+ fg: CREW.ishmael,
276
155
  },
277
156
  focus: {
278
157
  border: {
279
- fg: "blue",
158
+ fg: CREW.queequeg,
280
159
  },
281
160
  },
282
161
  },
@@ -304,6 +183,7 @@ function runTui() {
304
183
  content: [
305
184
  "Enter edit/load",
306
185
  "n ad hoc case",
186
+ "+ chain second test",
307
187
  "x export .txt",
308
188
  "m export .md",
309
189
  "Tab next panel",
@@ -323,16 +203,15 @@ function runTui() {
323
203
  {
324
204
  key: "preTestProb",
325
205
  label: "Pre-test probability (%)",
326
- parser: (value) => {
327
- const normalised = core.normaliseDecimal(value);
328
- return normalised === "" ? NaN : parseFloat(normalised);
329
- },
206
+ parser: core.parseProbability,
330
207
  },
331
208
  ];
332
209
 
333
210
  const state = {
334
211
  activeCaseLabel: "Ad hoc case",
335
212
  activeDatasetKey: "ad_hoc",
213
+ originDatasetKey: null,
214
+ previousTestSnapshot: null,
336
215
  selectedFieldIndex: 0,
337
216
  modalOpen: false,
338
217
  rawInput: {
@@ -511,10 +390,11 @@ function runTui() {
511
390
  }
512
391
 
513
392
  const metrics = core.calculateMetrics(state.input);
393
+ const warnings = [...core.buildBiasWarnings(state.input), ...datasetStore.buildDatasetWarnings(datasetStore.getDataset(state.originDatasetKey))];
514
394
  resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
515
- resultsBox.setContent(buildResultSummary(state.activeCaseLabel, state.input, metrics));
395
+ resultsBox.setContent(buildResultSummary(state.activeCaseLabel, state.input, metrics, warnings));
516
396
  resultsBox.setScrollPerc(0);
517
- setFeedback("Calculation complete.");
397
+ setFeedback(warnings.length > 0 ? `Calculation complete (${warnings.length} warning${warnings.length === 1 ? "" : "s"}).` : "Calculation complete.");
518
398
  screen.render();
519
399
  }
520
400
 
@@ -526,6 +406,8 @@ function runTui() {
526
406
 
527
407
  state.activeCaseLabel = dataset.name;
528
408
  state.activeDatasetKey = dataset.key;
409
+ state.originDatasetKey = dataset.key;
410
+ state.previousTestSnapshot = null;
529
411
  state.selectedFieldIndex = 0;
530
412
  setFieldValue("tp", String(dataset.tp));
531
413
  setFieldValue("fp", String(dataset.fp));
@@ -539,6 +421,8 @@ function runTui() {
539
421
  }
540
422
 
541
423
  function resetForm() {
424
+ state.originDatasetKey = null;
425
+ state.previousTestSnapshot = null;
542
426
  resetFieldValues();
543
427
  state.activeCaseLabel = "Ad hoc case";
544
428
  state.activeDatasetKey = "ad_hoc";
@@ -552,6 +436,8 @@ function runTui() {
552
436
  }
553
437
 
554
438
  function startAdHocCase() {
439
+ state.originDatasetKey = null;
440
+ state.previousTestSnapshot = null;
555
441
  state.activeCaseLabel = "Ad hoc case";
556
442
  state.activeDatasetKey = "ad_hoc";
557
443
  state.selectedFieldIndex = 0;
@@ -566,6 +452,42 @@ function runTui() {
566
452
  screen.render();
567
453
  }
568
454
 
455
+ function chainSecondTest() {
456
+ const validation = core.validateInputs(state.input);
457
+ if (!validation.valid) {
458
+ setFeedback("Calculate a valid first test before chaining.");
459
+ screen.render();
460
+ return;
461
+ }
462
+ const m = core.calculateMetrics(state.input);
463
+ const posteriorFraction = m.postTestPositive.value;
464
+ if (!Number.isFinite(posteriorFraction)) {
465
+ setFeedback("First test's post-test (+) is not finite; cannot chain.");
466
+ screen.render();
467
+ return;
468
+ }
469
+ const newPrePercent = core.chainedPreTestProbability(posteriorFraction);
470
+ state.previousTestSnapshot = require("../lib/diagcalc-case").createSnapshot(state.input, {}, {
471
+ label: state.activeCaseLabel, datasetKey: state.originDatasetKey,
472
+ modified: state.activeDatasetKey === "ad_hoc" && Boolean(state.originDatasetKey),
473
+ provenance: datasetStore.getDataset(state.originDatasetKey)?.provenance || null,
474
+ });
475
+ state.originDatasetKey = null;
476
+ const previousLabel = state.activeCaseLabel.replace(/^Chained from: /, "");
477
+ state.activeCaseLabel = `Chained from: ${previousLabel}`;
478
+ state.activeDatasetKey = "ad_hoc";
479
+ state.selectedFieldIndex = 0;
480
+ resetFieldValues();
481
+ setFieldValue("preTestProb", String(newPrePercent));
482
+ datasetInfo.setContent(`{bold}Chained second test{/bold}\nPre-test set to ${core.formatPercentage(newPrePercent / 100)} from previous post-test (+). Results must be conditionally independent given disease status.\n\nEnter the new TP/FP/FN/TN, then the result panel will update.`);
483
+ updateInputForm();
484
+ resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
485
+ resultsBox.setContent(buildPendingResults(state.activeCaseLabel, `Pre-test chained to ${core.formatPercentage(newPrePercent / 100)}. Enter the second test's matrix.`));
486
+ setFeedback(`Chained: pre-test now ${core.formatPercentage(newPrePercent / 100)} (from previous post-test +).`);
487
+ inputForm.focus();
488
+ screen.render();
489
+ }
490
+
569
491
  function editSelectedField() {
570
492
  const selectedIndex = state.selectedFieldIndex;
571
493
  const field = fields[selectedIndex];
@@ -613,7 +535,7 @@ function runTui() {
613
535
  setFieldValue(field.key, "");
614
536
  } else if (typeof character === "string" && character !== "") {
615
537
  const next = sanitiseRawInput(field.key, `${current}${character}`);
616
- if (next === current && !isAcceptedCharacter(field.key, character, current)) {
538
+ if (!isAcceptedCharacter(field.key, character, current)) {
617
539
  return;
618
540
  }
619
541
  setFieldValue(field.key, next);
@@ -635,7 +557,7 @@ function runTui() {
635
557
  const defaultPath = path.join(process.cwd(), currentCaseSlug(extension));
636
558
 
637
559
  runModal((done) => {
638
- prompt.input(`Export ${format} to path`, defaultPath, (error, outputPath) => {
560
+ prompt.input(`Export ${format} to a new path`, defaultPath, async (error, outputPath) => {
639
561
  if (!error && outputPath !== null) {
640
562
  const trimmedPath = outputPath.trim();
641
563
  if (!trimmedPath) {
@@ -651,7 +573,7 @@ function runTui() {
651
573
  : buildTextExport(state, metrics, validation);
652
574
 
653
575
  try {
654
- fs.writeFileSync(trimmedPath, content, "utf8");
576
+ await fs.promises.writeFile(trimmedPath, content, { encoding: "utf8", flag: "wx" });
655
577
  setFeedback(`Exported ${format} report to ${trimmedPath}`);
656
578
  message.display(`{green-fg}Saved{/}\n${trimmedPath}`, 3, () => {
657
579
  done();
@@ -721,10 +643,12 @@ function runTui() {
721
643
  resultsBox.key(["pagedown"], () => resultsBox.scroll(10));
722
644
 
723
645
  screen.key(["C-c"], () => {
646
+ screen.destroy();
724
647
  process.exit(0);
725
648
  });
726
649
  screen.key(["q"], () => {
727
650
  if (!state.modalOpen) {
651
+ screen.destroy();
728
652
  process.exit(0);
729
653
  }
730
654
  });
@@ -733,6 +657,11 @@ function runTui() {
733
657
  startAdHocCase();
734
658
  }
735
659
  });
660
+ screen.key(["+"], () => {
661
+ if (!state.modalOpen) {
662
+ chainSecondTest();
663
+ }
664
+ });
736
665
  screen.key(["r"], () => {
737
666
  if (!state.modalOpen) {
738
667
  resetForm();
@@ -787,8 +716,11 @@ function buildPendingResults(caseLabel, message) {
787
716
  ].join("\n");
788
717
  }
789
718
 
790
- function buildResultSummary(caseLabel, input, metrics) {
719
+ function buildResultSummary(caseLabel, input, metrics, warnings) {
791
720
  const totals = buildTotals(input);
721
+ const warningsBlock = Array.isArray(warnings) && warnings.length > 0
722
+ ? `\n{${"#DEC577"}-fg}{bold}Heads up:{/bold}{/}\n${warnings.map((w) => ` • ${w}`).join("\n")}\n`
723
+ : "";
792
724
  const caseSummary = [
793
725
  buildStatusStrip({
794
726
  caseLabel,
@@ -796,7 +728,7 @@ function buildResultSummary(caseLabel, input, metrics) {
796
728
  statusColour: "green",
797
729
  summary: `LR+ ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)} | LR- ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)} | Post+ ${core.formatValue(metrics.postTestPositive.value)} | Post- ${core.formatValue(metrics.postTestNegative.value)}`,
798
730
  }),
799
- "",
731
+ warningsBlock,
800
732
  "{bold}Case Overview{/bold}",
801
733
  buildConfusionMatrixBox(input),
802
734
  `Diseased ${totals.diseased} Non-diseased ${totals.nonDiseased} Total ${totals.total}`,
@@ -857,139 +789,10 @@ function renderFieldCell(state, fields, index, width = 10) {
857
789
  return `{bold}${padded}{/bold}`;
858
790
  }
859
791
 
860
- function sanitiseRawInput(fieldKey, rawValue) {
861
- if (typeof rawValue !== "string") {
862
- return "";
863
- }
864
-
865
- if (fieldKey === "preTestProb") {
866
- let cleaned = rawValue.replace(/,/g, ".").replace(/[^0-9.]/g, "");
867
- const firstDotIndex = cleaned.indexOf(".");
868
- if (firstDotIndex !== -1) {
869
- cleaned = `${cleaned.slice(0, firstDotIndex + 1)}${cleaned.slice(firstDotIndex + 1).replace(/\./g, "")}`;
870
- }
871
- return cleaned;
872
- }
873
-
874
- return rawValue.replace(/\D/g, "");
875
- }
876
-
877
- function isAcceptedCharacter(fieldKey, character, current) {
878
- if (fieldKey === "preTestProb") {
879
- if (/^[0-9]$/.test(character)) {
880
- return true;
881
- }
882
- if ((character === "." || character === ",") && !current.includes(".") && !current.includes(",")) {
883
- return true;
884
- }
885
- return false;
886
- }
887
-
888
- return /^[0-9]$/.test(character);
889
- }
890
-
891
792
  function buildStatusStrip({ caseLabel, status, statusColour, summary }) {
892
793
  return `{bold}${caseLabel}{/bold} {${statusColour}-fg}[${status}]{/} ${summary}`;
893
794
  }
894
795
 
895
- function buildTextExport(state, metrics, validation) {
896
- const lines = [
897
- `DIAGCALC - ${state.activeCaseLabel}`,
898
- "",
899
- "Confusion matrix",
900
- ` TP ${displayValue(state.input.tp)} FP ${displayValue(state.input.fp)}`,
901
- ` FN ${displayValue(state.input.fn)} TN ${displayValue(state.input.tn)}`,
902
- ` Pre-test probability ${formatPreTest(state.input.preTestProb)}`,
903
- "",
904
- ];
905
-
906
- if (!validation.valid || !metrics) {
907
- lines.push(`Validation: ${validation.message}`);
908
- return `${lines.join("\n")}\n`;
909
- }
910
-
911
- lines.push(...buildPlainMetricSections(metrics));
912
- return `${lines.join("\n")}\n`;
913
- }
914
-
915
- function buildMarkdownExport(state, metrics, validation) {
916
- const lines = [
917
- `# DIAGCALC - ${state.activeCaseLabel}`,
918
- "",
919
- "## Case Input",
920
- "",
921
- "| | Disease + | Disease - |",
922
- "|----------------|-----------|-----------|",
923
- `| Test + | ${displayValue(state.input.tp)} | ${displayValue(state.input.fp)} |`,
924
- `| Test - | ${displayValue(state.input.fn)} | ${displayValue(state.input.tn)} |`,
925
- "",
926
- `Pre-test probability: ${formatPreTest(state.input.preTestProb)}`,
927
- "",
928
- ];
929
-
930
- if (!validation.valid || !metrics) {
931
- lines.push("## Validation");
932
- lines.push("");
933
- lines.push(validation.message);
934
- lines.push("");
935
- return `${lines.join("\n")}`;
936
- }
937
-
938
- lines.push(...buildMarkdownMetricSections(metrics));
939
- return `${lines.join("\n")}`;
940
- }
941
-
942
- function buildPlainMetricSections(metrics) {
943
- return [
944
- "Performance Measures",
945
- ` Sensitivity: ${core.formatValue(metrics.sensitivity.value)} (${renderConfidence(metrics.sensitivity)})`,
946
- ` Specificity: ${core.formatValue(metrics.specificity.value)} (${renderConfidence(metrics.specificity)})`,
947
- ` PPV: ${core.formatValue(metrics.ppv.value)} (${renderConfidence(metrics.ppv)})`,
948
- ` NPV: ${core.formatValue(metrics.npv.value)} (${renderConfidence(metrics.npv)})`,
949
- "",
950
- "Bayesian Update",
951
- ` LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
952
- ` LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
953
- ` Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
954
- ` Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
955
- "",
956
- "Interpretation",
957
- ` Sensitivity: ${metrics.sensitivity.note}`,
958
- ` Specificity: ${metrics.specificity.note}`,
959
- ` LR+: ${metrics.lrPositive.note}`,
960
- ` LR-: ${metrics.lrNegative.note}`,
961
- ];
962
- }
963
-
964
- function buildMarkdownMetricSections(metrics) {
965
- return [
966
- "## Performance Measures",
967
- "",
968
- "| Metric | Value | Confidence interval |",
969
- "|--------|-------|---------------------|",
970
- `| Sensitivity | ${core.formatValue(metrics.sensitivity.value)} | ${renderConfidence(metrics.sensitivity)} |`,
971
- `| Specificity | ${core.formatValue(metrics.specificity.value)} | ${renderConfidence(metrics.specificity)} |`,
972
- `| PPV | ${core.formatValue(metrics.ppv.value)} | ${renderConfidence(metrics.ppv)} |`,
973
- `| NPV | ${core.formatValue(metrics.npv.value)} | ${renderConfidence(metrics.npv)} |`,
974
- "",
975
- "## Bayesian Update",
976
- "",
977
- `- LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
978
- `- LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
979
- `- Pre-test probability: ${core.formatValue(metrics.preTestProbability.value)}`,
980
- `- Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
981
- `- Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
982
- "",
983
- "## Interpretation",
984
- "",
985
- `- Sensitivity: ${metrics.sensitivity.note}`,
986
- `- Specificity: ${metrics.specificity.note}`,
987
- `- LR+: ${metrics.lrPositive.note}`,
988
- `- LR-: ${metrics.lrNegative.note}`,
989
- "",
990
- ];
991
- }
992
-
993
796
  module.exports = {
994
797
  runTui,
995
798
  };
package/tui/input.js ADDED
@@ -0,0 +1,24 @@
1
+ function sanitiseRawInput(fieldKey, rawValue) {
2
+ if (typeof rawValue !== "string") {
3
+ return "";
4
+ }
5
+
6
+ // Preserve complete input for strict parsing. Never turn -5 into 5 or 1.5 into 15.
7
+ return rawValue.trim();
8
+ }
9
+
10
+ function isAcceptedCharacter(fieldKey, character, current) {
11
+ if (fieldKey === "preTestProb") {
12
+ if (/^[0-9]$/.test(character)) {
13
+ return true;
14
+ }
15
+ if ((character === "." || character === ",") && !current.includes(".") && !current.includes(",")) {
16
+ return true;
17
+ }
18
+ return false;
19
+ }
20
+
21
+ return /^[0-9]$/.test(character);
22
+ }
23
+
24
+ module.exports = { sanitiseRawInput, isAcceptedCharacter };
@@ -0,0 +1,168 @@
1
+ const core = require("../lib/diagcalc-core");
2
+
3
+ function renderResults(metrics) {
4
+ const performance = [
5
+ renderMetricLine(metrics.sensitivity, "sensitivity"),
6
+ renderMetricLine(metrics.specificity, "specificity"),
7
+ renderMetricLine(metrics.ppv, "ppv"),
8
+ renderMetricLine(metrics.npv, "npv"),
9
+ renderMetricLine(metrics.dor, "dor"),
10
+ renderMetricLine(metrics.numberNeededToScreen, "nns"),
11
+ ];
12
+
13
+ const bayes = [
14
+ renderMetricLine(metrics.lrPositive, "lrPositive"),
15
+ renderMetricLine(metrics.lrNegative, "lrNegative"),
16
+ renderMetricLine(metrics.preTestProbability, "neutral"),
17
+ renderMetricLine(metrics.postTestPositive, "postPositive"),
18
+ renderMetricLine(metrics.postTestNegative, "postNegative"),
19
+ ];
20
+
21
+ const bars = [
22
+ `Pre-test [${core.buildProbabilityBar(metrics.preTestProbability.value, 24)}] ${core.formatPercentage(metrics.preTestProbability.value)}`,
23
+ `Post-test (+) [${core.buildProbabilityBar(metrics.postTestPositive.value, 24)}] ${core.formatPercentage(metrics.postTestPositive.value)}`,
24
+ `Post-test (-) [${core.buildProbabilityBar(metrics.postTestNegative.value, 24)}] ${core.formatPercentage(metrics.postTestNegative.value)}`,
25
+ ];
26
+
27
+ return [
28
+ "{bold}Performance Measures{/bold}",
29
+ performance.join("\n"),
30
+ "",
31
+ "{bold}Bayesian Update{/bold}",
32
+ bayes.join("\n"),
33
+ "",
34
+ "{bold}Probability Shift{/bold}",
35
+ bars.join("\n"),
36
+ "",
37
+ "{bold}Interpretation{/bold}",
38
+ `Sensitivity ${metrics.sensitivity.note}`,
39
+ `Specificity ${metrics.specificity.note}`,
40
+ `LR+ ${metrics.lrPositive.note}`,
41
+ `LR- ${metrics.lrNegative.note}`,
42
+ ].join("\n");
43
+ }
44
+
45
+ function renderMetricLine(metric, kind) {
46
+ const value = core.formatValue(metric.value, metric.formatter);
47
+ const valueTag = colourValue(value, kind, metric.value);
48
+ const confidence = renderConfidence(metric);
49
+ const confidenceText = confidence ? ` {gray-fg}${confidence}{/}` : "";
50
+ return `${metric.label.padEnd(36)} ${valueTag}${confidenceText}`;
51
+ }
52
+
53
+ function renderConfidence(metric) {
54
+ if (!metric.ci) {
55
+ return "";
56
+ }
57
+
58
+ const lo = core.formatValue(metric.ci.lower, metric.formatter);
59
+ const hi = core.formatValue(metric.ci.upper, metric.formatter);
60
+ return `95% CI ${lo} to ${hi}`;
61
+ }
62
+
63
+ function colourValue(value, kind, numericValue) {
64
+ const { colour, badge } = metricTone(kind, numericValue);
65
+ return `{${colour}-fg}${value}{/} {bold}{${colour}-fg}[${badge}]{/}{/}`;
66
+ }
67
+
68
+ // Pequod crew accents — dark variants (TUI assumes dark terminal background)
69
+ const CREW = {
70
+ ahab: "#E3877C", // red — WEAK / LOW / HIGH
71
+ starbuck: "#A6DFFF", // cyan — INFO
72
+ queequeg: "#838CCF", // blue — primary / selection / focus
73
+ pip: "#DEC577", // gold — MOD / USEFUL / LIMITED / MID
74
+ ishmael: "#BFBBB6", // warm gray — borders / muted
75
+ stubb: "#FFD9BB", // warm orange — LR+ accent
76
+ tashtego: "#82C4A2", // green — STRONG / RULE-IN / RULE-OUT / HIGH(post+) / LOW(post-)
77
+ daggoo: "#A17069", // brown — EXTREME (infinite values) / second-stage trace
78
+ };
79
+
80
+ function metricTone(kind, value) {
81
+ if (Number.isNaN(value)) return { colour: CREW.ishmael, badge: "UNDEFINED" };
82
+ if (!Number.isFinite(value)) {
83
+ return { colour: CREW.daggoo, badge: "EXTREME" };
84
+ }
85
+
86
+ if (kind === "sensitivity" || kind === "specificity" || kind === "ppv" || kind === "npv") {
87
+ if (value >= 0.9) {
88
+ return { colour: CREW.tashtego, badge: "STRONG" };
89
+ }
90
+ if (value >= 0.7) {
91
+ return { colour: CREW.pip, badge: "MOD" };
92
+ }
93
+ return { colour: CREW.ahab, badge: "WEAK" };
94
+ }
95
+
96
+ if (kind === "lrPositive") {
97
+ if (value >= 10) {
98
+ return { colour: CREW.tashtego, badge: "RULE-IN" };
99
+ }
100
+ if (value >= 5) {
101
+ return { colour: CREW.pip, badge: "USEFUL" };
102
+ }
103
+ if (value >= 2) {
104
+ return { colour: CREW.pip, badge: "LIMITED" };
105
+ }
106
+ return { colour: CREW.ahab, badge: "WEAK" };
107
+ }
108
+
109
+ if (kind === "dor") {
110
+ if (value >= 100) {
111
+ return { colour: CREW.tashtego, badge: "VERY STRONG" };
112
+ }
113
+ if (value >= 10) {
114
+ return { colour: CREW.tashtego, badge: "USEFUL" };
115
+ }
116
+ if (value > 1) {
117
+ return { colour: CREW.pip, badge: "LIMITED" };
118
+ }
119
+ return { colour: CREW.ahab, badge: "POOR" };
120
+ }
121
+
122
+ if (kind === "nns") {
123
+ if (value < 10) {
124
+ return { colour: CREW.tashtego, badge: "EFFICIENT" };
125
+ }
126
+ if (value < 100) {
127
+ return { colour: CREW.pip, badge: "MODERATE" };
128
+ }
129
+ return { colour: CREW.ahab, badge: "LOW YIELD" };
130
+ }
131
+
132
+ if (kind === "lrNegative") {
133
+ if (value <= 0.1) {
134
+ return { colour: CREW.tashtego, badge: "RULE-OUT" };
135
+ }
136
+ if (value <= 0.2) {
137
+ return { colour: CREW.pip, badge: "USEFUL" };
138
+ }
139
+ if (value <= 0.5) {
140
+ return { colour: CREW.pip, badge: "LIMITED" };
141
+ }
142
+ return { colour: CREW.ahab, badge: "WEAK" };
143
+ }
144
+
145
+ if (kind === "postPositive") {
146
+ if (value >= 0.8) {
147
+ return { colour: CREW.tashtego, badge: "HIGH" };
148
+ }
149
+ if (value >= 0.4) {
150
+ return { colour: CREW.pip, badge: "MID" };
151
+ }
152
+ return { colour: CREW.ahab, badge: "LOW" };
153
+ }
154
+
155
+ if (kind === "postNegative") {
156
+ if (value <= 0.1) {
157
+ return { colour: CREW.tashtego, badge: "LOW" };
158
+ }
159
+ if (value <= 0.3) {
160
+ return { colour: CREW.pip, badge: "MID" };
161
+ }
162
+ return { colour: CREW.ahab, badge: "HIGH" };
163
+ }
164
+
165
+ return { colour: CREW.starbuck, badge: "INFO" };
166
+ }
167
+
168
+ module.exports = { renderResults, renderConfidence, CREW };