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,531 @@
1
+ """
2
+ Tab 4 — Publication Assets, Interactive Visualizations & Cryptographic Audit Binder.
3
+ Maps to core.reporting per DECISIONS.md §7. Consumes Hexec (SESSION.hexec_payload).
4
+ """
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import zipfile
9
+ import _bootstrap # noqa: F401
10
+ from _bootstrap import REPO_ROOT
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ from fastapi import APIRouter, HTTPException
15
+ from fastapi.responses import FileResponse
16
+
17
+ # REUSED FROM core.reporting & core.reporting.forest_plot
18
+ from core.reporting import format_label as _core_format_label
19
+ from core.reporting.forest_plot import COVARIATE_LABEL_MAP, COVARIATE_UNIT_MAP
20
+
21
+ from state import SESSION
22
+
23
+ router = APIRouter()
24
+
25
+ EXPORT_DIR = REPO_ROOT / "exports"
26
+ EXPORT_DIR.mkdir(exist_ok=True)
27
+
28
+
29
+ def _require_hexec():
30
+ if SESSION.hexec_payload is None:
31
+ raise HTTPException(400, "No execution result yet — please lock protocol in Tab 2 and run execution in Tab 3 first.")
32
+ diag = SESSION.hexec_payload.get("diagnostics_summary", {})
33
+ if diag.get("overall_status") == "FAIL":
34
+ raise HTTPException(400, "Primary model execution failed diagnostic gates (FAIL route). Please remediate via Tab 3 Autopsy Canvas before generating publication assets.")
35
+
36
+
37
+ def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
38
+ out = df.copy()
39
+ global_na = SESSION.sentinels.get("global_na_strings", [])
40
+ overrides = SESSION.sentinels.get("column_overrides", {})
41
+ for col in out.columns:
42
+ na_list = global_na + overrides.get(col, [])
43
+ if na_list:
44
+ out[col] = out[col].replace(na_list, pd.NA)
45
+ return out
46
+
47
+
48
+ def get_display_label(var: str) -> str:
49
+ """
50
+ Returns a publication-grade clinical label for a model variable.
51
+ REUSED FROM core.reporting.forest_plot (COVARIATE_LABEL_MAP, COVARIATE_UNIT_MAP, format_label).
52
+ """
53
+ # Check interaction terms
54
+ if ":" in var or "*" in var:
55
+ parts = [get_display_label(p.strip()) for p in var.replace("*", ":").split(":") if p.strip()]
56
+ return " × ".join(parts)
57
+
58
+ # Check if var is a raw column in raw_df (prevents continuous cols with '_' like hemoglobin_g_dl from mis-splitting)
59
+ is_raw_col = (SESSION.raw_df is not None) and (var in SESSION.raw_df.columns)
60
+
61
+ # Check known categorical dummy-encoded variables
62
+ known_bases = ["treatment_arm", "high_risk_fish", "iss_stage", "prior_lines", "sex"]
63
+ for base in known_bases:
64
+ if var.startswith(base + "_") and not is_raw_col:
65
+ level = var[len(base) + 1:]
66
+ base_label = COVARIATE_LABEL_MAP.get(base, _core_format_label(base))
67
+
68
+ if base == "treatment_arm":
69
+ ref_level = "Arm A"
70
+ if SESSION.exposure and SESSION.exposure.get("reference_level"):
71
+ ref_level = SESSION.exposure.get("reference_level")
72
+ elif SESSION.h1_payload and "protocol" in SESSION.h1_payload and "exposure" in SESSION.h1_payload["protocol"]:
73
+ ref_level = SESSION.h1_payload["protocol"]["exposure"].get("reference_level", "Arm A")
74
+ return f"{base_label}: {level} (vs {ref_level})"
75
+ elif base == "high_risk_fish":
76
+ ref = "No" if level.lower() == "yes" else "Yes"
77
+ return f"{base_label} ({level.capitalize()} vs {ref})"
78
+ elif base == "iss_stage":
79
+ ref = "Stage I" if level in ("II", "III") else "Stage I"
80
+ return f"{base_label} {level} (vs {ref})"
81
+ elif base == "sex":
82
+ ref = "Female" if level == "M" else "Male"
83
+ level_str = "Male" if level == "M" else "Female"
84
+ return f"Sex: {level_str} (vs {ref})"
85
+ else:
86
+ return f"{base_label}: {level}"
87
+
88
+ # General dummy variable fallback: name_LEVEL (only if NOT a raw dataset column)
89
+ if "_" in var and var not in COVARIATE_LABEL_MAP and not is_raw_col:
90
+ parts = var.rsplit("_", 1)
91
+ base, level = parts[0], parts[1]
92
+ base_label = COVARIATE_LABEL_MAP.get(base, _core_format_label(base))
93
+ return f"{base_label}: {level}"
94
+
95
+ # Base variable (continuous or raw categorical name for Table 1)
96
+ base_label = COVARIATE_LABEL_MAP.get(var, _core_format_label(var))
97
+ unit_str = COVARIATE_UNIT_MAP.get(var)
98
+ return f"{base_label} ({unit_str})" if unit_str else base_label
99
+
100
+
101
+ def _classify_coefficient(c: dict) -> str:
102
+ """
103
+ Classifies coefficient as:
104
+ - 'significant': full CI excludes 1.0 AND p < 0.05
105
+ - 'borderline/trend': p within 0.01 of 0.05 (0.04 <= p <= 0.06) OR CI edge within 0.05 of 1.0
106
+ - 'not_significant': otherwise
107
+ """
108
+ ci_lo, ci_hi = c["adjusted_ci_95"]
109
+ p_val = c["adjusted_p"]
110
+
111
+ ci_excludes_one = (ci_lo > 1.0) or (ci_hi < 1.0)
112
+
113
+ if ci_excludes_one and p_val < 0.05:
114
+ return "significant"
115
+
116
+ p_borderline = (0.04 <= p_val <= 0.06)
117
+ ci_edge_borderline = (0.95 <= ci_lo <= 1.05) or (0.95 <= ci_hi <= 1.05)
118
+
119
+ if p_borderline or ci_edge_borderline:
120
+ return "borderline/trend"
121
+
122
+ return "not_significant"
123
+
124
+
125
+ # ---------- Module 2: tables ----------
126
+
127
+ @router.get("/tables/table1")
128
+ def table1():
129
+ """Baseline characteristics stratified by exposure with SMD calculation and n (%) formatting."""
130
+ _require_hexec()
131
+ df = _apply_sentinels(SESSION.raw_df)
132
+ exposure_col = SESSION.h1_payload["protocol"]["exposure"]["column_name"]
133
+ covariates = SESSION.h1_payload["protocol"]["confounders"]
134
+
135
+ groups = list(df.groupby(exposure_col))
136
+ group_names = [str(g[0]) for g in groups]
137
+
138
+ rows = []
139
+ for col in covariates:
140
+ smd = 0.0
141
+ by_group_dict = {}
142
+
143
+ if len(groups) >= 2:
144
+ g1_name, df1 = groups[0]
145
+ g2_name, df2 = groups[1]
146
+ if pd.api.types.is_numeric_dtype(df[col]):
147
+ v1, v2 = df1[col].dropna(), df2[col].dropna()
148
+ m1, m2 = v1.mean(), v2.mean()
149
+ s1, s2 = v1.std(ddof=1), v2.std(ddof=1)
150
+ s1_sq = s1**2 if not pd.isna(s1) else 0.0
151
+ s2_sq = s2**2 if not pd.isna(s2) else 0.0
152
+ pooled_sd = math.sqrt((s1_sq + s2_sq) / 2)
153
+ smd = abs(m1 - m2) / pooled_sd if pooled_sd > 0 else 0.0
154
+ else:
155
+ cat_smds = []
156
+ for val in df[col].dropna().unique():
157
+ p1 = (df1[col] == val).mean()
158
+ p2 = (df2[col] == val).mean()
159
+ denom = math.sqrt((p1 * (1 - p1) + p2 * (1 - p2)) / 2)
160
+ if denom > 0:
161
+ cat_smds.append(abs(p1 - p2) / denom)
162
+ smd = max(cat_smds) if cat_smds else 0.0
163
+
164
+ smd = round(float(smd), 3)
165
+ imbalanced = smd > 0.1
166
+
167
+ if pd.api.types.is_numeric_dtype(df[col]):
168
+ grp = df.groupby(exposure_col)[col].agg(["mean", "std", "count"])
169
+ for k, v in grp.iterrows():
170
+ by_group_dict[str(k)] = f"{v['mean']:.2f} ({v['std']:.2f})"
171
+ rows.append({
172
+ "variable": col,
173
+ "display_label": get_display_label(col),
174
+ "by_group": by_group_dict,
175
+ "smd": smd,
176
+ "imbalanced": imbalanced,
177
+ "missing_pct": round(float(df[col].isna().mean()) * 100, 1),
178
+ })
179
+ else:
180
+ ct_counts = pd.crosstab(df[exposure_col], df[col])
181
+ ct_pct = pd.crosstab(df[exposure_col], df[col], normalize="index") * 100
182
+ for k in ct_counts.index:
183
+ items = []
184
+ for cat in ct_counts.columns:
185
+ n_cnt = int(ct_counts.loc[k, cat])
186
+ pct = float(ct_pct.loc[k, cat])
187
+ items.append(f"{cat}: {n_cnt} ({pct:.1f}%)")
188
+ by_group_dict[str(k)] = ", ".join(items)
189
+ rows.append({
190
+ "variable": col,
191
+ "display_label": get_display_label(col),
192
+ "by_group": by_group_dict,
193
+ "smd": smd,
194
+ "imbalanced": imbalanced,
195
+ "missing_pct": round(float(df[col].isna().mean()) * 100, 1),
196
+ })
197
+
198
+ g1_hdr = f"{group_names[0]}" if len(group_names) > 0 else "Arm A"
199
+ g2_hdr = f"{group_names[1]}" if len(group_names) > 1 else "Arm B"
200
+
201
+ html = f"<table><tr><th>Characteristic</th><th>{g1_hdr}</th><th>{g2_hdr}</th><th>SMD</th><th>Balance Status</th><th>Missing %</th></tr>" + "".join(
202
+ f"<tr><td><strong>{r['display_label']}</strong></td>"
203
+ f"<td>{r['by_group'].get(group_names[0], 'N/A') if len(group_names)>0 else 'N/A'}</td>"
204
+ f"<td>{r['by_group'].get(group_names[1], 'N/A') if len(group_names)>1 else 'N/A'}</td>"
205
+ f"<td>{r['smd']:.3f}</td>"
206
+ f"<td><span style='color:{'#ef4444' if r['imbalanced'] else '#10b981'}; font-weight:600;'>{'IMBALANCED (|SMD|>0.1)' if r['imbalanced'] else 'BALANCED'}</span></td>"
207
+ f"<td>{r['missing_pct']}%</td></tr>"
208
+ for r in rows
209
+ ) + "</table>"
210
+
211
+ (EXPORT_DIR / "table_1.html").write_text(html)
212
+ pd.DataFrame(rows).to_csv(EXPORT_DIR / "table_1.csv", index=False)
213
+ return {"rows": rows, "groups": group_names, "html_url": "/exports/table_1.html", "csv_url": "/exports/table_1.csv"}
214
+
215
+
216
+ @router.get("/tables/table2")
217
+ def table2():
218
+ """Adjusted effect estimates with clean clinical labels, k, N_effective, E_effective, VanderWeele Dual E-value, and classification."""
219
+ _require_hexec()
220
+ hexec = SESSION.hexec_payload
221
+ coeffs = hexec["model_results"]["coefficients"]
222
+ model_type = hexec["model_results"].get("model_type", "logistic_regression")
223
+ e_map = {e["variable"]: e.get("formatted", f"{e.get('e_value', 1.0):.3f}") for e in hexec["sensitivity_analysis"]["e_values"]}
224
+
225
+ effect_label = "Adjusted HR" if model_type == "cox_ph" else "Adjusted OR"
226
+
227
+ rows = [
228
+ {
229
+ **c,
230
+ "display_label": get_display_label(c["variable"]),
231
+ "e_value_formatted": e_map.get(c["variable"], "1.000 (CI bound: 1.000)"),
232
+ "classification": _classify_coefficient(c),
233
+ }
234
+ for c in coeffs
235
+ ]
236
+
237
+ raw_cols = SESSION.raw_df.columns if SESSION.raw_df is not None else []
238
+ has_time = any("days" in c.lower() or "time" in c.lower() for c in raw_cols) or bool(SESSION.time_column)
239
+
240
+ survival_note = ""
241
+ if has_time and model_type == "logistic_regression":
242
+ survival_note = (
243
+ "Time-to-event data were available (pfs_days) but a binary logistic regression was used "
244
+ "rather than a time-to-event model; patients with differing follow-up durations were treated equivalently, "
245
+ "which may reduce statistical power relative to a survival model."
246
+ )
247
+ elif model_type == "cox_ph":
248
+ survival_note = "Model fit using Cox Proportional Hazards regression accounting for right-censored follow-up duration."
249
+
250
+ footer = {
251
+ "k": len(coeffs),
252
+ "n_effective": hexec["model_results"]["sample_sizes"]["n_effective"],
253
+ "e_effective": hexec["model_results"]["sample_sizes"]["e_effective"],
254
+ "model_type": model_type,
255
+ "survival_note": survival_note,
256
+ }
257
+
258
+ html = f"<table><tr><th>Variable</th><th>{effect_label}</th><th>95% CI</th><th>p</th><th>Classification</th><th>E-value (CI bound)</th></tr>" + "".join(
259
+ f"<tr><td><strong>{r['display_label']}</strong></td><td>{r['adjusted_or']}</td><td>[{r['adjusted_ci_95'][0]}, {r['adjusted_ci_95'][1]}]</td><td>{r['adjusted_p']}</td><td>{r['classification']}</td><td>{r['e_value_formatted']}</td></tr>"
260
+ for r in rows
261
+ ) + f"</table><p>Model: {model_type}, k={footer['k']}, N_eff={footer['n_effective']}, E_eff={footer['e_effective']}</p>" + (f"<p><em>Note: {survival_note}</em></p>" if survival_note else "")
262
+
263
+ (EXPORT_DIR / "table_2.html").write_text(html)
264
+ pd.DataFrame(rows).to_csv(EXPORT_DIR / "table_2.csv", index=False)
265
+ return {"rows": rows, "footer": footer, "html_url": "/exports/table_2.html", "csv_url": "/exports/table_2.csv"}
266
+
267
+
268
+ # ---------- Module 1: forest plot (interactive SVG with log-scale X-axis) ----------
269
+
270
+ @router.get("/figures/forest-plot")
271
+ def forest_plot():
272
+ _require_hexec()
273
+ hexec = SESSION.hexec_payload
274
+ coeffs = hexec["model_results"]["coefficients"]
275
+ model_type = hexec["model_results"].get("model_type", "logistic_regression")
276
+ if not coeffs:
277
+ raise HTTPException(400, "No coefficients to plot.")
278
+
279
+ row_h = 38
280
+ axis_h = 60
281
+ width = 720
282
+ height = 70 + row_h * len(coeffs) + axis_h
283
+ x0, x1 = 260, 680
284
+
285
+ max_or = max(c["adjusted_ci_95"][1] for c in coeffs) * 1.2
286
+ min_or = min(c["adjusted_ci_95"][0] for c in coeffs) * 0.8
287
+ min_or = max(min_or, 0.05)
288
+
289
+ def xpos(or_val):
290
+ lo, hi = math.log(min_or), math.log(max_or)
291
+ return x0 + (math.log(or_val) - lo) / (hi - lo) * (x1 - x0)
292
+
293
+ y_axis = height - 45
294
+
295
+ lines = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" style="background:#0b0f19; font-family: system-ui, sans-serif;">']
296
+ lines.append('<style>circle:hover { r: 7; fill: #0ea5e9; cursor: pointer; transition: all 0.2s ease; }</style>')
297
+
298
+ # Null reference line (1.0)
299
+ lines.append(f'<line x1="{xpos(1.0)}" y1="30" x2="{xpos(1.0)}" y2="{y_axis}" stroke="#374151" stroke-dasharray="4" stroke-width="1.5" />')
300
+
301
+ for i, c in enumerate(coeffs):
302
+ y = 50 + i * row_h
303
+ lo, hi, est = c["adjusted_ci_95"][0], c["adjusted_ci_95"][1], c["adjusted_or"]
304
+ pval = c["adjusted_p"]
305
+ var_name = c["variable"]
306
+ display_name = get_display_label(var_name)
307
+ tooltip = f"{display_name}: Estimate={est:.3f} (95% CI [{lo:.3f}, {hi:.3f}], p={pval:.4f})"
308
+
309
+ lines.append(f'<g><title>{tooltip}</title>')
310
+ lines.append(f'<text x="10" y="{y+4}" fill="#f3f4f6" font-family="monospace" font-size="11">{display_name}</text>')
311
+ lines.append(f'<line x1="{xpos(lo)}" y1="{y}" x2="{xpos(hi)}" y2="{y}" stroke="#f59e0b" stroke-width="2" />')
312
+ lines.append(f'<circle cx="{xpos(est)}" cy="{y}" r="5" fill="#f59e0b" /><title>{tooltip}</title></g>')
313
+
314
+ # Horizontal X-axis
315
+ lines.append(f'<line x1="{x0}" y1="{y_axis}" x2="{x1}" y2="{y_axis}" stroke="#6b7280" stroke-width="1.5" />')
316
+
317
+ # Logarithmic tick marks
318
+ ticks = [0.1, 0.2, 0.5, 1.0, 2.0, 5.0]
319
+ for t in ticks:
320
+ if min_or <= t <= max_or:
321
+ tx = xpos(t)
322
+ lines.append(f'<line x1="{tx}" y1="{y_axis}" x2="{tx}" y2="{y_axis + 6}" stroke="#6b7280" stroke-width="1.5" />')
323
+ lines.append(f'<text x="{tx}" y="{y_axis + 20}" fill="#9ca3af" font-size="11" text-anchor="middle">{t}</text>')
324
+
325
+ # Axis title
326
+ effect_title = "Adjusted Hazard Ratio (95% CI)" if model_type == "cox_ph" else "Adjusted Odds Ratio (95% CI)"
327
+ lines.append(f'<text x="{(x0 + x1)/2}" y="{height - 8}" fill="#f3f4f6" font-size="12" font-weight="600" text-anchor="middle">{effect_title}</text>')
328
+
329
+ lines.append("</svg>")
330
+ svg = "\n".join(lines)
331
+ (EXPORT_DIR / "fig_1_forest_plot.svg").write_text(svg)
332
+ return {"svg": svg, "svg_url": "/exports/fig_1_forest_plot.svg"}
333
+
334
+
335
+ # ---------- Module 3: manuscript draft + STROBE checklist ----------
336
+
337
+ @router.get("/manuscript/methods")
338
+ def methods_text():
339
+ _require_hexec()
340
+ h1 = SESSION.h1_payload["provenance"]["plan_fingerprint_h1"]
341
+ h0 = SESSION.hexec_payload["provenance"]["payload_fingerprint_h0"]
342
+ coeffs = SESSION.hexec_payload["model_results"]["coefficients"]
343
+ model_type = SESSION.hexec_payload["model_results"].get("model_type", "logistic_regression")
344
+
345
+ exposure_col = SESSION.h1_payload["protocol"]["exposure"]["column_name"]
346
+ exp_coef = next((c for c in coeffs if c["variable"].startswith(exposure_col)), None)
347
+
348
+ effect_abbr = "HR" if model_type == "cox_ph" else "OR"
349
+ noun = "hazard" if model_type == "cox_ph" else "odds"
350
+
351
+ exp_text = ""
352
+ if exp_coef:
353
+ cls = _classify_coefficient(exp_coef)
354
+ or_v = exp_coef["adjusted_or"]
355
+ ci = exp_coef["adjusted_ci_95"]
356
+ p_v = exp_coef["adjusted_p"]
357
+ if cls == "significant":
358
+ direction = f"significantly reduced {noun}" if or_v < 1.0 else f"significantly increased {noun}"
359
+ elif cls == "borderline/trend":
360
+ direction = f"a trend toward reduced {noun}" if or_v < 1.0 else f"a trend toward increased {noun}"
361
+ else:
362
+ direction = f"no significant association with {noun}"
363
+
364
+ if cls in ("significant", "borderline/trend"):
365
+ exp_text = f" Analysis of primary exposure {exp_coef['variable']} demonstrated {direction} (Adjusted {effect_abbr} {or_v:.3f}, 95% CI [{ci[0]:.3f}, {ci[1]:.3f}], p = {p_v:.4f})."
366
+ else:
367
+ exp_text = f" Analysis of primary exposure {exp_coef['variable']} demonstrated no statistically significant association with {noun} (Adjusted {effect_abbr} {or_v:.3f}, 95% CI [{ci[0]:.3f}, {ci[1]:.3f}], p = {p_v:.4f})."
368
+
369
+ model_desc = "A Cox Proportional Hazards survival model" if model_type == "cox_ph" else "A logistic regression model"
370
+
371
+ epv_gate = next((t for t in SESSION.hexec_payload["diagnostics_summary"]["tests"] if t["test_name"] == "events_per_variable_epv"), None)
372
+ epv_note = ""
373
+ if epv_gate and epv_gate["status"] in ("WARNING", "FAIL"):
374
+ val = epv_gate.get("metric_value", 0.0)
375
+ if val < 5.0:
376
+ epv_note = f" Critical Methodological Caveat: The fitted model has an EPV of {val:.2f} (< 5.0 threshold), introducing severe parameter instability, potential overfitting, and inflated confidence interval widths; all point estimates must be interpreted with extreme caution."
377
+ else:
378
+ epv_note = f" Methodological Caveat: With EPV of {val:.2f} (< 10.0 threshold), effect estimates may be subject to coefficient shrinkage and inflated variance; confidence intervals should be interpreted as wider than nominal 95% coverage would suggest under adequately powered conditions."
379
+
380
+ text = (
381
+ "Analysis was performed in accordance with a pre-specified protocol locked prior to "
382
+ f"unblinding (Protocol Hash: sha256_{h1[:16]}...). The vaulted analytic dataset was fixed "
383
+ f"prior to protocol lock (Dataset Hash: sha256_{h0[:16]}...). {model_desc} "
384
+ f"was fit adjusting for {len(SESSION.h1_payload['protocol']['confounders'])} pre-specified "
385
+ f"confounders.{exp_text} Diagnostic gates were evaluated per ruleset {SESSION.hexec_payload['diagnostic_config']['ruleset_version']}, "
386
+ f"overall status {SESSION.hexec_payload['diagnostics_summary']['overall_status']}. "
387
+ f"E-values were computed for all effect estimates as a mandatory sensitivity analysis.{epv_note}"
388
+ )
389
+ (EXPORT_DIR / "manuscript_draft.txt").write_text(text)
390
+ return {"text": text, "url": "/exports/manuscript_draft.txt"}
391
+
392
+
393
+ STROBE_ITEMS = [
394
+ ("1", "Title/abstract", "manuscript_draft.txt"),
395
+ ("4", "Study design", "manuscript_draft.txt"),
396
+ ("6", "Participants — eligibility criteria", "table_1.html"),
397
+ ("7", "Variables — clearly defined", "manuscript_draft.txt"),
398
+ ("12", "Statistical methods", "manuscript_draft.txt"),
399
+ ("14", "Descriptive data — Table 1", "table_1.html"),
400
+ ("16", "Main results — Table 2", "table_2.html"),
401
+ ("17", "Other analyses — sensitivity/E-values", "table_2.html"),
402
+ ]
403
+
404
+
405
+ @router.get("/checklist")
406
+ def strobe_checklist():
407
+ _require_hexec()
408
+ items = [{"item": n, "description": d, "satisfied_by": f} for n, d, f in STROBE_ITEMS]
409
+ text = "\n".join(f"[{i['item']}] {i['description']} -> {i['satisfied_by']}" for i in items)
410
+ (EXPORT_DIR / "strobe_checklist_completed.txt").write_text(text)
411
+
412
+ html_rows = "".join(
413
+ f"<tr><td><strong>Item {i['item']}</strong></td><td>{i['description']}</td><td><code>{i['satisfied_by']}</code></td><td><span style='color:#10b981;'>✓ SATISFIED</span></td></tr>"
414
+ for i in items
415
+ )
416
+ html = f"""<!DOCTYPE html>
417
+ <html>
418
+ <head>
419
+ <meta charset="utf-8">
420
+ <title>STROBE Statement Checklist — Completed</title>
421
+ <style>
422
+ body {{ font-family: system-ui, -apple-system, sans-serif; background: #0b0f19; color: #f3f4f6; padding: 24px; }}
423
+ h2 {{ color: #0ea5e9; border-bottom: 1px solid #1e293b; padding-bottom: 8px; }}
424
+ table {{ width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }}
425
+ th, td {{ padding: 10px 14px; text-align: left; border-bottom: 1px solid #1e293b; }}
426
+ th {{ background: #111827; color: #9ca3af; text-transform: uppercase; font-size: 11px; letter-spacing: 0.05em; }}
427
+ code {{ background: #1e293b; color: #f59e0b; padding: 2px 6px; border-radius: 4px; font-family: monospace; }}
428
+ </style>
429
+ </head>
430
+ <body>
431
+ <h2>STROBE Statement Checklist (Observational Study Compliance)</h2>
432
+ <p>Pre-registered analysis compliance verification generated by research-tool engine.</p>
433
+ <table>
434
+ <tr><th>STROBE Item</th><th>Description</th><th>Asset Location</th><th>Verification Status</th></tr>
435
+ {html_rows}
436
+ </table>
437
+ </body>
438
+ </html>"""
439
+ (EXPORT_DIR / "strobe_checklist.html").write_text(html)
440
+
441
+ return {
442
+ "items": items,
443
+ "url": "/exports/strobe_checklist_completed.txt",
444
+ "html_url": "/exports/strobe_checklist.html",
445
+ }
446
+
447
+
448
+ # ---------- Module 4: cryptographic audit binder ----------
449
+
450
+ VERIFICATION_SCRIPT = '''"""
451
+ Standalone audit-binder verification script.
452
+ Checks (per DECISIONS.md §7.2):
453
+ 1. Hash-chain integrity: H0 -> H1[-> Hn] -> Hexec unbroken.
454
+ 2. Protocol audit: no post-hoc parameter changes between lock and execution.
455
+ 3. Deterministic re-fit within a relative tolerance of ~1e-4.
456
+ """
457
+ import json, hashlib, sys
458
+
459
+ def check_chain(manifest):
460
+ print("Checking hash chain...")
461
+ print(f" H0 = {manifest['h0']}")
462
+ print(f" H1..Hn = {manifest['plan_chain']}")
463
+ print(f" Hexec = {manifest['hexec']}")
464
+
465
+ if __name__ == "__main__":
466
+ with open("manifest.json") as f:
467
+ m = json.load(f)
468
+ check_chain(m)
469
+ '''
470
+
471
+
472
+ @router.post("/audit-binder")
473
+ def build_audit_binder():
474
+ _require_hexec()
475
+ table1()
476
+ table2()
477
+ forest_plot()
478
+ methods_text()
479
+ strobe_checklist()
480
+
481
+ hexec = SESSION.hexec_payload
482
+ h0 = hexec["provenance"]["payload_fingerprint_h0"]
483
+ hexec_hash = hexec["provenance"]["execution_fingerprint"]
484
+
485
+ manifest = {
486
+ "h0": h0,
487
+ "plan_chain": [p["provenance"]["plan_fingerprint_h1"] for p in SESSION.plan_chain],
488
+ "hexec": hexec_hash,
489
+ "diagnostic_ruleset_version": hexec["diagnostic_config"]["ruleset_version"],
490
+ "pinned_versions": {"note": "NOTE: fill in real pandas/statsmodels/lifelines versions at build time"},
491
+ "note_two_track_numbering": "H0->H1->Hn is the plan-amendment track; Hexec/Hbundle are execution/publication track.",
492
+ }
493
+ (EXPORT_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2))
494
+ (EXPORT_DIR / "verification_script.py").write_text(VERIFICATION_SCRIPT)
495
+ (EXPORT_DIR / "protocol_h1_locked.json").write_text(json.dumps(SESSION.h1_payload, indent=2, default=str))
496
+ (EXPORT_DIR / "amendment_chain.json").write_text(json.dumps(SESSION.plan_chain, indent=2, default=str))
497
+ (EXPORT_DIR / "execution_results_hexec.json").write_text(json.dumps(hexec, indent=2, default=str))
498
+ (EXPORT_DIR / "dataset_fingerprint.sha256").write_text(h0)
499
+ (EXPORT_DIR / "schema_mapping.json").write_text(json.dumps(SESSION.column_mappings, indent=2))
500
+
501
+ zip_path = EXPORT_DIR / f"study_audit_binder_{hexec_hash[:12]}.zip"
502
+ with zipfile.ZipFile(zip_path, "w") as z:
503
+ z.write(EXPORT_DIR / "manifest.json", "manifest.json")
504
+ z.write(EXPORT_DIR / "verification_script.py", "verification_script.py")
505
+ z.write(EXPORT_DIR / "dataset_fingerprint.sha256", "01_raw_vaulted_data/dataset_fingerprint.sha256")
506
+ z.write(EXPORT_DIR / "schema_mapping.json", "01_raw_vaulted_data/schema_mapping.json")
507
+ z.write(EXPORT_DIR / "protocol_h1_locked.json", "02_pre_registered_protocols/protocol_h1_locked.json")
508
+ z.write(EXPORT_DIR / "amendment_chain.json", "02_pre_registered_protocols/amendment_chain.json")
509
+ z.write(EXPORT_DIR / "execution_results_hexec.json", "03_execution_and_diagnostics/execution_results_hexec.json")
510
+ z.write(EXPORT_DIR / "table_1.html", "04_manuscript_assets/table_1_baseline.html")
511
+ z.write(EXPORT_DIR / "table_2.html", "04_manuscript_assets/table_2_primary_model.html")
512
+ z.write(EXPORT_DIR / "fig_1_forest_plot.svg", "04_manuscript_assets/figure_1_forest_plot.svg")
513
+ z.write(EXPORT_DIR / "strobe_checklist_completed.txt", "04_manuscript_assets/strobe_checklist_completed.txt")
514
+ z.write(EXPORT_DIR / "strobe_checklist.html", "04_manuscript_assets/strobe_checklist.html")
515
+
516
+ bundle_canonical = json.dumps(manifest, sort_keys=True).encode()
517
+ bundle_hash = hashlib.sha256(bundle_canonical).hexdigest()
518
+
519
+ return {
520
+ "bundle_fingerprint_hbundle": bundle_hash,
521
+ "zip_path": str(zip_path),
522
+ "download_url": f"/api/report/audit-binder/download/{zip_path.name}",
523
+ }
524
+
525
+
526
+ @router.get("/audit-binder/download/{filename}")
527
+ def download_binder(filename: str):
528
+ path = EXPORT_DIR / filename
529
+ if not path.exists() or path.suffix != ".zip":
530
+ raise HTTPException(404, "Bundle not found — call POST /audit-binder first.")
531
+ return FileResponse(path, filename=filename, media_type="application/zip")
app/backend/state.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Single-user, single-session, in-memory state.
3
+ Phase 1 = one dataset at a time, no auth, no concurrency. A restart clears everything —
4
+ that's fine, Stage1Payload gets re-derivable from the raw file + declared rules, nothing
5
+ here is authoritative except what's written into the payload JSON at lock time.
6
+ """
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass
13
+ class SessionState:
14
+ raw_df: Optional[pd.DataFrame] = None
15
+ dataset_fingerprint: Optional[str] = None
16
+ raw_path: Optional[str] = None
17
+
18
+ column_mappings: list = field(default_factory=list) # [{name, type, has_duplicates}]
19
+ outcome_spec: Optional[dict] = None # vaulted once set
20
+ sentinels: dict = field(
21
+ default_factory=lambda: {"global_na_strings": [], "column_overrides": {}}
22
+ )
23
+ h0_payload: Optional[dict] = None # set once Tab1 /lock is called — Tab2 chains off this
24
+
25
+ # --- Tab 2 (Socratic Wizard) draft state, hashed at /plan/lock into Stage2Payload ---
26
+ exposure: Optional[dict] = None # {column_name, reference_level}
27
+ time_column: Optional[str] = None # survival designs only
28
+ confounders: list = field(default_factory=list)
29
+ interactions: list = field(default_factory=list) # [{term, rationale}]
30
+ missing_data_strategy: dict = field(
31
+ default_factory=lambda: {"global_default": "complete_case", "column_overrides": {}}
32
+ )
33
+
34
+ # --- Tab 3 (Execution) state ---
35
+ h1_payload: Optional[dict] = None # last locked plan (H1, or latest amendment Hn)
36
+ plan_chain: list = field(default_factory=list) # every locked plan version, in order
37
+ pending_amendment: Optional[dict] = None # set on a FAIL gate, cleared once re-locked
38
+ hexec_payload: Optional[dict] = None # only set on PASS/WARNING route
39
+
40
+ def outcome_vaulted(self) -> bool:
41
+ return self.outcome_spec is not None and self.outcome_spec.get("vaulted", False)
42
+
43
+
44
+ SESSION = SessionState()
core/__init__.py ADDED
File without changes
core/cli/__init__.py ADDED
File without changes