research-tool-cli 0.1.0__py3-none-any.whl

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.
Files changed (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -0,0 +1,614 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from core.database import DATA_ROOT, get_connection, init_db
12
+ from core.planning.diagnostics import check_violation
13
+ from core.reporting import latest_locked_plan as _latest_locked_plan, svg_escape as _svg_escape
14
+
15
+
16
+ COVARIATE_LABEL_MAP = {
17
+ "treatment_arm": "Treatment Group",
18
+ "high_risk_fish": "High-Risk Cytogenetics",
19
+ "prior_lines": "Prior Lines of Therapy",
20
+ "age": "Age, years",
21
+ "iss_stage": "ISS Stage",
22
+ }
23
+
24
+ COVARIATE_UNIT_MAP = {
25
+ "prior_lines": "Per additional line",
26
+ "age": "Per year increase",
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class CovariateRow:
32
+ covariate: str
33
+ hr: float
34
+ ci_lower: float
35
+ ci_upper: float
36
+ wald_p: float
37
+ coef: float | None
38
+ se: float | None
39
+ z: float | None
40
+ reference_level: str | None = None
41
+ tested_level: str | None = None
42
+ unstable: bool = False
43
+ violated: bool = False
44
+
45
+ @property
46
+ def ci_crosses_one(self) -> bool:
47
+ return self.ci_lower <= 1 <= self.ci_upper
48
+
49
+ @property
50
+ def display_label(self) -> str:
51
+ base_name = COVARIATE_LABEL_MAP.get(self.covariate, self.covariate.replace("_", " ").title())
52
+ if self.reference_level is not None and self.tested_level is not None:
53
+ ref_str = str(self.reference_level).capitalize() if str(self.reference_level).lower() in ("yes", "no") else str(self.reference_level)
54
+ test_str = str(self.tested_level).capitalize() if str(self.tested_level).lower() in ("yes", "no") else str(self.tested_level)
55
+ return f"{base_name} ({test_str} vs {ref_str})"
56
+ if " (interaction)" in self.covariate:
57
+ return self.covariate
58
+ unit_str = COVARIATE_UNIT_MAP.get(self.covariate, "per 1-unit increase")
59
+ return f"{base_name} ({unit_str})"
60
+
61
+
62
+ @dataclass
63
+ class ForestPlotData:
64
+ result_id: int
65
+ study_plan_version: int
66
+ concordance_index: float | None
67
+ lr_test_p: float | None
68
+ n_analyzed: int
69
+ n_events: int
70
+ epv: float | None
71
+ epv_warning: bool
72
+ covariates: list[CovariateRow]
73
+ violation_warning: bool = False
74
+ violation_summary: str = ""
75
+
76
+
77
+ def _extract_epv(warnings_text: str | None) -> tuple[float | None, bool]:
78
+ if not warnings_text:
79
+ return None, False
80
+ m = re.search(r"EPV=([\d.]+)", warnings_text)
81
+ if m:
82
+ epv = float(m.group(1))
83
+ return epv, epv < 10
84
+ return None, False
85
+
86
+
87
+ def _get_event_count(study_id: str, event_col: str, conn) -> int:
88
+ for table in (f"raw_{study_id}", f"raw_masked_{study_id}"):
89
+ try:
90
+ row = conn.execute(
91
+ f'SELECT COUNT("{event_col}") AS n_observed, '
92
+ f'SUM(CASE WHEN CAST("{event_col}" AS INTEGER) = 1 THEN 1 ELSE 0 END) '
93
+ f'AS n_events FROM {table}'
94
+ ).fetchone()
95
+ except Exception:
96
+ continue
97
+ if row and int(row["n_observed"] or 0) > 0:
98
+ return int(row["n_events"] or 0)
99
+ return 0
100
+
101
+
102
+ def load_forest_data(study_id: str) -> ForestPlotData:
103
+ conn = get_connection(study_id)
104
+ init_db(conn)
105
+
106
+ plan = _latest_locked_plan(study_id)
107
+
108
+ warnings_text = json.dumps(plan.get("warnings", {}))
109
+ epv, epv_warn = _extract_epv(warnings_text)
110
+
111
+ result = conn.execute(
112
+ """SELECT id, study_plan_version, concordance_index, lr_test_p,
113
+ sample_counts_json, status_json, ph_diagnostics_json
114
+ FROM analysis_results
115
+ WHERE study_id=? AND test_name='cox_ph_model'
116
+ AND id NOT IN (
117
+ SELECT COALESCE(superseded_previous_result_id, -1)
118
+ FROM analysis_results
119
+ WHERE study_id=? AND test_name='cox_ph_model'
120
+ AND superseded_previous_result_id IS NOT NULL
121
+ )
122
+ ORDER BY id DESC
123
+ LIMIT 1""",
124
+ (study_id, study_id),
125
+ ).fetchone()
126
+ if not result:
127
+ conn.close()
128
+ raise ValueError(f"No non-superseded cox_ph_model result found for {study_id}")
129
+
130
+ rd = dict(result)
131
+ result_id = rd["id"]
132
+
133
+ sc = json.loads(rd["sample_counts_json"]) if rd.get("sample_counts_json") else {}
134
+ n_analyzed = sc.get("n_analyzed", 0)
135
+
136
+ # Get event column from study plan's cox model config
137
+ event_col = None
138
+ cox_models = plan.get("cox_ph_models", [])
139
+ if cox_models:
140
+ event_col = cox_models[0].get("event_col")
141
+ n_events = _get_event_count(study_id, event_col, conn) if event_col else 0
142
+
143
+ cov_rows = conn.execute(
144
+ """SELECT covariate, hr, ci_lower, ci_upper, wald_p, coef, se, z,
145
+ reference_level, tested_level
146
+ FROM analysis_covariate_results
147
+ WHERE result_id=?
148
+ ORDER BY id""",
149
+ (result_id,),
150
+ ).fetchall()
151
+
152
+ covariates: list[CovariateRow] = []
153
+ for r in cov_rows:
154
+ cd = dict(r)
155
+ unstable = (
156
+ cd.get("hr") is None
157
+ or cd.get("ci_lower") is None
158
+ or cd.get("ci_upper") is None
159
+ or (cd.get("hr") is not None and (math.isinf(cd["hr"]) or math.isnan(cd["hr"])))
160
+ or (cd.get("ci_lower") is not None and (math.isinf(cd["ci_lower"]) or math.isnan(cd["ci_lower"])))
161
+ or (cd.get("ci_upper") is not None and (math.isinf(cd["ci_upper"]) or math.isnan(cd["ci_upper"])))
162
+ )
163
+ covariates.append(CovariateRow(
164
+ covariate=cd["covariate"],
165
+ hr=cd["hr"] if cd.get("hr") is not None else 0,
166
+ ci_lower=cd["ci_lower"] if cd.get("ci_lower") is not None else 0,
167
+ ci_upper=cd["ci_upper"] if cd.get("ci_upper") is not None else 0,
168
+ wald_p=cd["wald_p"] if cd.get("wald_p") is not None else 1.0,
169
+ coef=cd.get("coef"),
170
+ se=cd.get("se"),
171
+ z=cd.get("z"),
172
+ reference_level=cd.get("reference_level"),
173
+ tested_level=cd.get("tested_level"),
174
+ unstable=unstable,
175
+ ))
176
+
177
+ conn.close()
178
+
179
+ return ForestPlotData(
180
+ result_id=result_id,
181
+ study_plan_version=rd["study_plan_version"],
182
+ concordance_index=rd.get("concordance_index"),
183
+ lr_test_p=rd.get("lr_test_p"),
184
+ n_analyzed=n_analyzed,
185
+ n_events=n_events,
186
+ epv=epv,
187
+ epv_warning=epv_warn,
188
+ covariates=covariates,
189
+ )
190
+
191
+
192
+ def _populate_violation(data: ForestPlotData, result_row: dict) -> ForestPlotData:
193
+ """Add post-unmask violation info from result row to ForestPlotData.
194
+ Also marks individual CovariateRow.violated for matched Schoenfeld violations."""
195
+ has_viol, summary, details = check_violation(result_row)
196
+ data.violation_warning = has_viol
197
+ data.violation_summary = summary
198
+ if has_viol:
199
+ ph_raw = result_row.get("ph_diagnostics_json")
200
+ if ph_raw:
201
+ import json
202
+ ph = json.loads(ph_raw) if isinstance(ph_raw, str) else ph_raw
203
+ violated_bases: list[str] = []
204
+ for cov in ph.get("covariates", []):
205
+ p = cov.get("p_value", 1)
206
+ if p < 0.05:
207
+ name = cov.get("covariate", "")
208
+ # Strip lifelines' [T.level] suffix for matching
209
+ base = name.split("[")[0].strip()
210
+ violated_bases.append(base)
211
+ for cv in data.covariates:
212
+ if cv.covariate in violated_bases:
213
+ cv.violated = True
214
+ return data
215
+
216
+
217
+
218
+ # ── SVG renderer ─────────────────────────────────────────────────────────────
219
+
220
+ def _log_scale_x(hr: float, x_min: float, x_max: float, width: float) -> float:
221
+ if hr <= 0 or x_min <= 0 or x_max <= 0:
222
+ return width / 2
223
+ return (math.log(hr) - math.log(x_min)) / (math.log(x_max) - math.log(x_min)) * width
224
+
225
+
226
+ SVG_TPL = """<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"
227
+ font-family="Arial, Helvetica, sans-serif" font-size="13">
228
+ {body}
229
+ </svg>"""
230
+
231
+
232
+ def render_svg(data: ForestPlotData, output_path: str) -> None:
233
+ margin_l = 20
234
+ margin_r = 30
235
+ margin_t = 40
236
+ margin_b = 80
237
+ label_w = 280
238
+ plot_l = margin_l + label_w + 20 # 320
239
+ plot_w = 400
240
+ row_h = 36
241
+ txt_x = plot_l + plot_w + 20 # 740
242
+ p_col_offset = 190 # 930 for p-value column
243
+ text_col_w = 270
244
+
245
+ svg_w = txt_x + text_col_w + margin_r # 1040
246
+ n = len(data.covariates)
247
+ plot_h = max(n * row_h, 60)
248
+ svg_h = margin_t + plot_h + margin_b
249
+
250
+ # Determine log range: include all CIs and HR=1, with padding for x-axis ticks
251
+ all_vals = [1.0]
252
+ for c in data.covariates:
253
+ if not c.unstable:
254
+ all_vals.extend([c.hr, c.ci_lower, c.ci_upper])
255
+ min_v = min(all_vals)
256
+ max_v = max(all_vals)
257
+
258
+ lo = min(0.04 if min_v < 0.1 else 0.08, min_v / 1.5)
259
+ hi = max(15.0 if max_v > 8.0 else 12.0, max_v * 1.5)
260
+ if lo <= 0:
261
+ lo = 0.04
262
+
263
+ svg_header = f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" ' \
264
+ f'font-family="Arial, Helvetica, sans-serif" font-size="13">'
265
+
266
+ parts: list[str] = []
267
+
268
+ parts.append(f' <text x="{margin_l}" y="24" font-size="16" font-weight="bold" '
269
+ f'fill="#1F4E78">Forest Plot — Cox PH Model</text>')
270
+
271
+ # Header row — side-by-side column headers aligned with data columns
272
+ header_y = margin_t
273
+ parts.append(f' <text x="{margin_l}" y="{header_y}" font-size="11" '
274
+ f'font-weight="bold" fill="#555">Covariate</text>')
275
+ parts.append(f' <text x="{txt_x}" y="{header_y}" font-size="11" '
276
+ f'font-weight="bold" fill="#555">aHR [95% CI]</text>')
277
+ parts.append(f' <text x="{txt_x + p_col_offset}" y="{header_y}" font-size="11" '
278
+ f'font-weight="bold" fill="#555">p-value</text>')
279
+
280
+ # Horizontal reference line at HR=1
281
+ ref_x = _log_scale_x(1.0, lo, hi, plot_w) + plot_l
282
+ parts.append(
283
+ f' <line x1="{ref_x}" y1="{margin_t}" x2="{ref_x}" y2="{margin_t + plot_h}" '
284
+ f'stroke="#999" stroke-width="1.5" stroke-dasharray="6,3" />'
285
+ )
286
+
287
+ # X-axis
288
+ ax_y = margin_t + plot_h + 5
289
+ parts.append(f' <line x1="{plot_l}" y1="{ax_y}" x2="{plot_l + plot_w}" y2="{ax_y}" '
290
+ f'stroke="#333" stroke-width="1" />')
291
+ # Tick labels for x-axis (HR values) — include 0.05 and 15.0
292
+ tick_vals = [0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0]
293
+ tick_vals = [v for v in tick_vals if lo <= v <= hi]
294
+ for tv in tick_vals:
295
+ tx = _log_scale_x(tv, lo, hi, plot_w) + plot_l
296
+ parts.append(f' <line x1="{tx}" y1="{ax_y}" x2="{tx}" y2="{ax_y + 4}" '
297
+ f'stroke="#333" stroke-width="1" />')
298
+ parts.append(f' <text x="{tx}" y="{ax_y + 16}" font-size="10" fill="#666" '
299
+ f'text-anchor="middle">{tv}</text>')
300
+
301
+ # Render each covariate row
302
+ for i, cov in enumerate(data.covariates):
303
+ y = margin_t + i * row_h + row_h // 2 + 10
304
+
305
+ use_grey = cov.violated
306
+ label_color = "#888" if use_grey else "#333"
307
+
308
+ # Covariate label (left column)
309
+ parts.append(f' <text x="{margin_l}" y="{y + 4}" font-size="12" fill="{label_color}">'
310
+ f'{_svg_escape(cov.display_label)}</text>')
311
+
312
+ if cov.unstable:
313
+ parts.append(f' <text x="{plot_l + 10}" y="{y + 4}" font-size="11" '
314
+ f'fill="#E74C3C" font-style="italic">'
315
+ f'Did not converge / unstable estimate</text>')
316
+ continue
317
+
318
+ color = "#888" if cov.ci_crosses_one else "#1F4E78"
319
+
320
+ # CI line
321
+ lx = _log_scale_x(cov.ci_lower, lo, hi, plot_w) + plot_l
322
+ ux = _log_scale_x(cov.ci_upper, lo, hi, plot_w) + plot_l
323
+ parts.append(f' <line x1="{lx}" y1="{y}" x2="{ux}" y2="{y}" '
324
+ f'stroke="{color}" stroke-width="2.5" stroke-linecap="round" />')
325
+
326
+ # CI whiskers
327
+ whisk_h = 5
328
+ parts.append(f' <line x1="{lx}" y1="{y - whisk_h}" x2="{lx}" y2="{y + whisk_h}" '
329
+ f'stroke="{color}" stroke-width="1.5" />')
330
+ parts.append(f' <line x1="{ux}" y1="{y - whisk_h}" x2="{ux}" y2="{y + whisk_h}" '
331
+ f'stroke="{color}" stroke-width="1.5" />')
332
+
333
+ # Point estimate marker
334
+ hx = _log_scale_x(cov.hr, lo, hi, plot_w) + plot_l
335
+ if use_grey:
336
+ # Hollow diamond for violated covariates
337
+ d = 6 # half-size
338
+ parts.append(f' <polygon points="{hx},{y - d} {hx + d},{y} {hx},{y + d} {hx - d},{y}" '
339
+ f'fill="white" stroke="{color}" stroke-width="2" />')
340
+ else:
341
+ marker_size = 5
342
+ parts.append(f' <rect x="{hx - marker_size}" y="{y - marker_size}" '
343
+ f'width="{marker_size * 2}" height="{marker_size * 2}" '
344
+ f'fill="{color}" />')
345
+
346
+ # Text: HR [CI], p-value — 3dp to avoid hiding CI crossing 1.0 (§9 fix)
347
+ hr_str = f"{cov.hr:.3f}"
348
+ ci_str = f"[{cov.ci_lower:.3f}, {cov.ci_upper:.3f}]"
349
+ p_str = f"p = {cov.wald_p:.3f}"
350
+ txt_color = "#888" if use_grey else "#333"
351
+ parts.append(f' <text x="{txt_x}" y="{y + 4}" font-size="11" fill="{txt_color}">'
352
+ f'{hr_str} {ci_str}</text>')
353
+ parts.append(f' <text x="{txt_x + p_col_offset}" y="{y + 4}" font-size="11" '
354
+ f'fill="{txt_color}">{p_str}</text>')
355
+
356
+ # Footer: model summary
357
+ footer_y = margin_t + plot_h + 40
358
+ footer_lines = 0
359
+
360
+ summary_parts = []
361
+ if data.concordance_index is not None:
362
+ summary_parts.append(f"C-index={data.concordance_index:.3f}")
363
+ if data.lr_test_p is not None:
364
+ summary_parts.append(f"LR test p={data.lr_test_p:.4f}")
365
+ summary_parts.append(f"N={data.n_analyzed}")
366
+ summary_parts.append(f"Events={data.n_events}")
367
+ summary_str = " | ".join(summary_parts)
368
+ parts.append(f' <text x="{margin_l}" y="{footer_y}" font-size="11" fill="#555">'
369
+ f'{_svg_escape(summary_str)}</text>')
370
+ footer_lines += 1
371
+
372
+ # aHR footnote
373
+ parts.append(f' <text x="{margin_l}" y="{footer_y + 18}" font-size="10" fill="#555" '
374
+ f'font-style="italic">aHR = adjusted Hazard Ratio (multivariable Cox model)</text>')
375
+ footer_lines += 1
376
+
377
+ # EPV caveat
378
+ epv_y = footer_y + 30
379
+ if data.epv_warning and data.epv is not None:
380
+ parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="11" '
381
+ f'fill="#C0392B" font-weight="bold">'
382
+ f'⚠ Caution: EPV={data.epv:.1f}, below recommended threshold '
383
+ f'— estimates may be unstable.</text>')
384
+ epv_y += 16
385
+ footer_lines += 1
386
+
387
+ # Post-unmask violation annotation + legend
388
+ if data.violation_warning and data.violation_summary:
389
+ parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="11" '
390
+ f'fill="#C0392B" font-weight="bold">'
391
+ f'⚠ Assumption violation: {_svg_escape(data.violation_summary)}</text>')
392
+ epv_y += 16
393
+ footer_lines += 1
394
+ has_marked = any(c.violated for c in data.covariates)
395
+ if has_marked:
396
+ parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="10" '
397
+ f'fill="#888">'
398
+ f'◇ = assumption violation detected for this covariate</text>')
399
+ epv_y += 16
400
+ footer_lines += 1
401
+
402
+ # Resize canvas if footer overflowed the original margin_b (§9 fix)
403
+ # Use epv_y (last text y-position) + descender + padding instead of line count
404
+ needed_footer_h = epv_y - (margin_t + plot_h) + 20
405
+ if needed_footer_h > margin_b:
406
+ svg_h = margin_t + plot_h + needed_footer_h
407
+ svg_header = f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" ' \
408
+ f'font-family="Arial, Helvetica, sans-serif" font-size="13">'
409
+
410
+ parts.insert(0, svg_header)
411
+ parts.append("</svg>")
412
+ Path(output_path).write_text("\n".join(parts))
413
+
414
+
415
+ # ── ASCII renderer ───────────────────────────────────────────────────────────
416
+
417
+ def render_ascii(data: ForestPlotData) -> str:
418
+ lines: list[str] = []
419
+ lines.append("Forest Plot — Cox PH Model")
420
+ lines.append("")
421
+
422
+ # Determine max covariate name width
423
+ max_name_len = max(len(c.display_label) for c in data.covariates) if data.covariates else 10
424
+ name_w = max(max_name_len + 2, 14)
425
+
426
+ # Build scale
427
+ all_vals = [1.0]
428
+ for c in data.covariates:
429
+ if not c.unstable and c.hr > 0 and c.ci_lower > 0 and c.ci_upper > 0:
430
+ all_vals.extend([c.hr, c.ci_lower, c.ci_upper])
431
+ lo = min(all_vals) / 1.3
432
+ hi = max(all_vals) * 1.3
433
+ if lo <= 0:
434
+ lo = 0.1
435
+
436
+ plot_w = 30
437
+ hr_w = 7
438
+ ci_w = 6
439
+
440
+ def _pos(v: float) -> int:
441
+ if v <= 0:
442
+ return plot_w // 2
443
+ return int((math.log(v) - math.log(lo)) / (math.log(hi) - math.log(lo)) * plot_w)
444
+
445
+ ref = _pos(1.0)
446
+
447
+ # Header row above covariate table — exact same format as data rows
448
+ header_name = "Covariate".ljust(name_w)
449
+ header_plot = " " * plot_w
450
+ header_hr = "aHR".rjust(hr_w)
451
+ header_ci = "[95% CI]".ljust(1 + ci_w + 2 + ci_w + 1)
452
+ header_p = "p-value".rjust(7)
453
+ lines.append(f"{header_name}│{header_plot} {header_hr} {header_ci} {header_p}")
454
+
455
+ # Header
456
+ scale_line = " " * name_w + "|"
457
+ tick_labels = [0.1, 0.2, 0.5, 1, 2, 5, 10]
458
+ tick_labels = [t for t in tick_labels if lo <= t <= hi]
459
+ for tv in tick_labels:
460
+ p = _pos(tv)
461
+ if p < 0:
462
+ continue
463
+ scale_line += f"{'─' * max(0, p - len(scale_line) + name_w + 1)}┬"
464
+ lines.append(scale_line)
465
+
466
+ tick_text = " " * name_w + "│"
467
+ for tv in tick_labels:
468
+ p = _pos(tv)
469
+ s = f"{tv}"
470
+ pad = p - len(tick_text) + name_w + 1
471
+ if pad > 0:
472
+ tick_text += " " * pad + s
473
+ lines.append(tick_text)
474
+
475
+ # Separator covers name│ [plot] HR [CI, CI] p-value
476
+ _ci_field = 1 + ci_w + 2 + ci_w + 1 # [lower, upper]
477
+ _data_w = 1 + plot_w + 2 + hr_w + 1 + _ci_field + 1 + 8 # │plot HR CI pv
478
+ lines.append("─" * (name_w + _data_w))
479
+
480
+ for cov in data.covariates:
481
+ name = cov.display_label.ljust(name_w)
482
+
483
+ if cov.unstable:
484
+ lines.append(f"{name}│ ** Did not converge / unstable estimate **")
485
+ continue
486
+
487
+ p = _pos(cov.hr)
488
+ lp = _pos(cov.ci_lower)
489
+ up = _pos(cov.ci_upper)
490
+
491
+ marker = "◇" if cov.ci_crosses_one else "◆"
492
+ color_tag = " (n.s.)" if cov.ci_crosses_one else ""
493
+
494
+ # Build plot line
495
+ plot_buf = [" "] * plot_w
496
+ for j in range(max(0, lp), min(plot_w, up + 1)):
497
+ plot_buf[j] = "─"
498
+ if 0 <= p < plot_w:
499
+ plot_buf[p] = marker
500
+ if 0 <= ref < plot_w:
501
+ plot_buf[ref] = "│"
502
+
503
+ plot_str = "".join(plot_buf)
504
+
505
+ hr_w = 7
506
+ # ci_w is already defined at function scope (line 361)
507
+ hr_str = f"{cov.hr:>{hr_w}.2f}"
508
+ ci_str = f"[{cov.ci_lower:>{ci_w}.2f}, {cov.ci_upper:>{ci_w}.2f}]"
509
+ p_str = f"p={cov.wald_p:.3f}"
510
+ lines.append(f"{name}│{plot_str} {hr_str} {ci_str} {p_str}{color_tag}")
511
+
512
+ lines.append("")
513
+
514
+ # Summary
515
+ summary_parts = []
516
+ if data.concordance_index is not None:
517
+ summary_parts.append(f"C-index={data.concordance_index:.3f}")
518
+ if data.lr_test_p is not None:
519
+ summary_parts.append(f"LR test p={data.lr_test_p:.4f}")
520
+ summary_parts.append(f"N={data.n_analyzed}")
521
+ summary_parts.append(f"Events={data.n_events}")
522
+ lines.append(" | ".join(summary_parts))
523
+ lines.append("aHR = adjusted Hazard Ratio (multivariable Cox model)")
524
+
525
+ if data.epv_warning and data.epv is not None:
526
+ lines.append(
527
+ f"⚠ Caution: EPV={data.epv:.1f}, below recommended threshold "
528
+ f"— estimates may be unstable."
529
+ )
530
+
531
+ return "\n".join(lines)
532
+
533
+
534
+ # ── Entry point ──────────────────────────────────────────────────────────────
535
+
536
+ def render_forest(study_id: str, output_path: str | None = None, ascii: bool = False) -> Path | str:
537
+ data = load_forest_data(study_id)
538
+ if ascii:
539
+ return render_ascii(data)
540
+ path = output_path or str(DATA_ROOT / study_id / "forest_plot.svg")
541
+ # Re-read the result row to pass status_json + ph_diagnostics_json to violation check
542
+ conn = get_connection(study_id)
543
+ init_db(conn)
544
+ row = conn.execute(
545
+ """SELECT status_json, ph_diagnostics_json
546
+ FROM analysis_results
547
+ WHERE study_id=? AND test_name='cox_ph_model'
548
+ AND id NOT IN (
549
+ SELECT COALESCE(superseded_previous_result_id, -1)
550
+ FROM analysis_results
551
+ WHERE study_id=? AND test_name='cox_ph_model'
552
+ AND superseded_previous_result_id IS NOT NULL
553
+ )
554
+ ORDER BY id DESC LIMIT 1""",
555
+ (study_id, study_id),
556
+ ).fetchone()
557
+ conn.close()
558
+ if row:
559
+ data = _populate_violation(data, dict(row))
560
+ render_svg(data, path)
561
+ return Path(path)
562
+
563
+
564
+ def generate_forest_plot_png(study_id: str, output_path: str | Path | None = None) -> Path:
565
+ """Render a publication-ready PNG forest plot using matplotlib."""
566
+ import matplotlib
567
+ matplotlib.use("Agg")
568
+ import matplotlib.pyplot as plt
569
+ import numpy as np
570
+
571
+ data = load_forest_data(study_id)
572
+ if output_path is None:
573
+ output_path = DATA_ROOT / study_id / "forest_plot.png"
574
+ else:
575
+ output_path = Path(output_path)
576
+
577
+ fig, ax = plt.subplots(figsize=(10, 4.5), dpi=150)
578
+ fig.subplots_adjust(left=0.32, right=0.62, top=0.85, bottom=0.2)
579
+
580
+ n = len(data.covariates)
581
+ y_positions = np.arange(n, 0, -1)
582
+
583
+ for i, cov in enumerate(data.covariates):
584
+ y = y_positions[i]
585
+ color = "#888888" if cov.ci_crosses_one else "#1F4E78"
586
+
587
+ err_low = max(0, cov.hr - cov.ci_lower)
588
+ err_high = max(0, cov.ci_upper - cov.hr)
589
+ ax.errorbar(
590
+ cov.hr, y, xerr=[[err_low], [err_high]],
591
+ fmt="s", color=color, ecolor=color, elinewidth=2, capsize=4, capthick=1.5, markersize=6,
592
+ )
593
+
594
+ ax.text(-0.03, y, cov.display_label, transform=ax.get_yaxis_transform(), ha="right", va="center", fontsize=11)
595
+
596
+ hr_str = f"{cov.hr:.2f} [{cov.ci_lower:.2f}, {cov.ci_upper:.2f}]"
597
+ p_str = f"p = {cov.wald_p:.3f}" if cov.wald_p >= 0.001 else "p < 0.001"
598
+ ax.text(1.03, y, hr_str, transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11)
599
+ ax.text(1.55, y, p_str, transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11)
600
+
601
+ ax.set_xscale("log")
602
+ ax.axvline(1.0, color="#999999", linestyle="--", linewidth=1.5)
603
+ ax.set_yticks([])
604
+ ax.set_ylim(0.5, n + 0.8)
605
+ ax.set_xlabel("Hazard Ratio (log scale)", fontsize=11)
606
+ ax.set_title("Forest Plot — Cox Proportional Hazards Model", fontsize=13, fontweight="bold", color="#1F4E78", pad=15)
607
+
608
+ ax.text(-0.03, n + 0.5, "Covariate", transform=ax.get_yaxis_transform(), ha="right", va="center", fontsize=11, fontweight="bold", color="#555555")
609
+ ax.text(1.03, n + 0.5, "aHR [95% CI]", transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11, fontweight="bold", color="#555555")
610
+ ax.text(1.55, n + 0.5, "p-value", transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11, fontweight="bold", color="#555555")
611
+
612
+ plt.savefig(str(output_path), bbox_inches="tight")
613
+ plt.close(fig)
614
+ return output_path