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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- 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
|