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,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend bootstrap module.
|
|
3
|
+
Ensures repository root and backend directory are registered in sys.path.
|
|
4
|
+
Exposes BACKEND_DIR and REPO_ROOT paths.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
BACKEND_DIR = Path(__file__).resolve().parent
|
|
10
|
+
REPO_ROOT = BACKEND_DIR.parent.parent
|
|
11
|
+
|
|
12
|
+
if str(REPO_ROOT) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
14
|
+
if str(BACKEND_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(BACKEND_DIR))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone audit-binder verification script.
|
|
3
|
+
Checks (per DECISIONS.md §7.2):
|
|
4
|
+
1. Hash-chain integrity: H0 -> H1[-> Hn] -> Hexec unbroken.
|
|
5
|
+
2. Protocol audit: no post-hoc parameter changes between lock and execution.
|
|
6
|
+
3. Deterministic re-fit within a relative tolerance of ~1e-4.
|
|
7
|
+
"""
|
|
8
|
+
import json, hashlib, sys
|
|
9
|
+
|
|
10
|
+
def check_chain(manifest):
|
|
11
|
+
print("Checking hash chain...")
|
|
12
|
+
print(f" H0 = {manifest['h0']}")
|
|
13
|
+
print(f" H1..Hn = {manifest['plan_chain']}")
|
|
14
|
+
print(f" Hexec = {manifest['hexec']}")
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
with open("manifest.json") as f:
|
|
18
|
+
m = json.load(f)
|
|
19
|
+
check_chain(m)
|
app/backend/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandbox-to-Vault app — FastAPI backend.
|
|
3
|
+
localhost only. Phase 1 = Tab 1, Tab 2, Tab 3, & Tab 4 wired live.
|
|
4
|
+
"""
|
|
5
|
+
from _bootstrap import BACKEND_DIR, REPO_ROOT # noqa: F401
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
|
|
11
|
+
from routers import ingestion, planning, execution, reporting
|
|
12
|
+
|
|
13
|
+
tags_metadata = [
|
|
14
|
+
{
|
|
15
|
+
"name": "Ingestion (Tab 1)",
|
|
16
|
+
"description": "Blinded dataset upload, schema mapping, sentinel configuration, and Stage 1 (H0) protocol vaulting.",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "Planning (Tab 2)",
|
|
20
|
+
"description": "Socratic Wizard for pre-registering exposure, confounders, missing-data strategy, live EPV calculations, and Stage 2 (H1) protocol lock.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "Execution (Tab 3)",
|
|
24
|
+
"description": "Unattended statistical execution (Logistic / Cox PH), 4 diagnostic gates (Separation, VIF, Proportional Hazards, Linearity), and Autopsy Canvas remediation routing.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "Reporting (Tab 4)",
|
|
28
|
+
"description": "Journal-ready manuscript assets (Table 1 Baseline Balance with SMD, Table 2 Primary Effect Estimates, Interactive SVG Forest Plots, STROBE Checklists, Cryptographic Audit Binder).",
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
app = FastAPI(
|
|
33
|
+
title="Clinical Research Tool — Sandbox-to-Vault Engine",
|
|
34
|
+
description="Publication-grade, audit-sealed epidemiological and clinical trial analytical framework.",
|
|
35
|
+
version="1.2.0-strobe-default",
|
|
36
|
+
openapi_tags=tags_metadata,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# CORS middleware for local SPA
|
|
40
|
+
app.add_middleware(
|
|
41
|
+
CORSMiddleware,
|
|
42
|
+
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8000"],
|
|
43
|
+
allow_methods=["*"],
|
|
44
|
+
allow_headers=["*"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
app.include_router(ingestion.router, prefix="/api/ingest", tags=["Ingestion (Tab 1)"])
|
|
48
|
+
app.include_router(planning.router, prefix="/api/plan", tags=["Planning (Tab 2)"])
|
|
49
|
+
app.include_router(execution.router, prefix="/api/execute", tags=["Execution (Tab 3)"])
|
|
50
|
+
app.include_router(reporting.router, prefix="/api/report", tags=["Reporting (Tab 4)"])
|
|
51
|
+
|
|
52
|
+
# Serve exports directory for HTML/SVG asset viewing
|
|
53
|
+
exports_dir = REPO_ROOT / "exports"
|
|
54
|
+
exports_dir.mkdir(exist_ok=True)
|
|
55
|
+
app.mount("/exports", StaticFiles(directory=str(exports_dir)), name="exports")
|
|
56
|
+
|
|
57
|
+
# Serve the vanilla JS SPA
|
|
58
|
+
frontend_dir = BACKEND_DIR.parent / "frontend"
|
|
59
|
+
app.mount("/", StaticFiles(directory=str(frontend_dir), html=True), name="frontend")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Routers package for research-tool backend API."""
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tab 3 — Execution Engine, Diagnostics & Autopsy Canvas. Maps to core.execution / core.diagnostics
|
|
3
|
+
per DECISIONS.md §6.
|
|
4
|
+
|
|
5
|
+
Supports both Logistic Regression (binary outcome) and Cox Proportional Hazards (survival outcome via lifelines).
|
|
6
|
+
Unattended, deterministic (§6): no manual tuning knobs exposed here by design — thresholds are
|
|
7
|
+
fixed constants below, not request parameters.
|
|
8
|
+
"""
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from state import SESSION
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
RULESET_VERSION = "v1.2.0-strobe-default"
|
|
23
|
+
THRESHOLDS = {
|
|
24
|
+
"vif_warning": 5.0,
|
|
25
|
+
"vif_fail": 10.0,
|
|
26
|
+
"ph_p_warning": 0.05,
|
|
27
|
+
"ph_p_fail": 0.01,
|
|
28
|
+
"separation_max_se": 100.0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
REMEDIATION_OPTIONS = {
|
|
32
|
+
"complete_separation": [
|
|
33
|
+
"Combine sparse categories causing the zero-cell / perfect predictor",
|
|
34
|
+
"Treat as a data artifact — check for a miscoded or duplicated variable",
|
|
35
|
+
],
|
|
36
|
+
"multicollinearity_vif": [
|
|
37
|
+
"Drop the more clinically redundant variable in the flagged pair",
|
|
38
|
+
"Combine correlated variables into a single composite score",
|
|
39
|
+
],
|
|
40
|
+
"proportional_hazards": [
|
|
41
|
+
"Stratify the model by the offending covariate",
|
|
42
|
+
"Model the covariate with a time-varying interaction (X × log(t))",
|
|
43
|
+
],
|
|
44
|
+
"linearity_continuous_terms": [
|
|
45
|
+
"Pre-specify a restricted cubic spline transform for the flagged variable",
|
|
46
|
+
"Categorize the continuous variable into clinically meaningful bins",
|
|
47
|
+
],
|
|
48
|
+
"events_per_variable_epv": [
|
|
49
|
+
"Prune non-essential confounders to restore EPV >= 10",
|
|
50
|
+
"Acknowledge critical parameter instability in protocol amendment rationale",
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AmendmentPrepareIn(BaseModel):
|
|
56
|
+
chosen_remediation: str
|
|
57
|
+
rationale: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _require_h1():
|
|
61
|
+
if SESSION.h1_payload is None:
|
|
62
|
+
raise HTTPException(400, "Lock Stage 2 first — no H1 plan to execute.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
|
|
66
|
+
out = df.copy()
|
|
67
|
+
global_na = SESSION.sentinels.get("global_na_strings", [])
|
|
68
|
+
overrides = SESSION.sentinels.get("column_overrides", {})
|
|
69
|
+
for col in out.columns:
|
|
70
|
+
na_list = global_na + overrides.get(col, [])
|
|
71
|
+
if na_list:
|
|
72
|
+
out[col] = out[col].replace(na_list, pd.NA)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fit_model(protocol: dict):
|
|
77
|
+
"""Fits Cox Proportional Hazards (if time column present) or Logistic Regression. Returns (fit_ok, results_dict)."""
|
|
78
|
+
df = _apply_sentinels(SESSION.raw_df)
|
|
79
|
+
outcome_col = SESSION.outcome_spec["column_name"]
|
|
80
|
+
exposure_col = protocol["exposure"]["column_name"]
|
|
81
|
+
confounders = protocol["confounders"]
|
|
82
|
+
interactions = protocol.get("interactions", [])
|
|
83
|
+
predictors = [exposure_col] + confounders
|
|
84
|
+
|
|
85
|
+
time_col = protocol["outcome_confirmation"].get("time_column") or SESSION.time_column
|
|
86
|
+
is_cox = bool(time_col and time_col in df.columns)
|
|
87
|
+
|
|
88
|
+
if is_cox:
|
|
89
|
+
# Fit Cox Proportional Hazards model using lifelines
|
|
90
|
+
try:
|
|
91
|
+
from lifelines import CoxPHFitter
|
|
92
|
+
from lifelines.statistics import proportional_hazard_test
|
|
93
|
+
except ImportError:
|
|
94
|
+
raise HTTPException(501, "lifelines not installed on this machine.")
|
|
95
|
+
|
|
96
|
+
cox_df = df.dropna(subset=[time_col, outcome_col] + predictors).copy()
|
|
97
|
+
y_event = (cox_df[outcome_col].astype(str) == str(SESSION.outcome_spec["event_value"])).astype(int)
|
|
98
|
+
|
|
99
|
+
X = pd.get_dummies(cox_df[predictors], drop_first=True).astype(float)
|
|
100
|
+
|
|
101
|
+
# Add interaction terms if specified
|
|
102
|
+
for inter in interactions:
|
|
103
|
+
term_str = inter.get("term", "")
|
|
104
|
+
parts = [p.strip() for p in term_str.replace("*", ":").split(":") if p.strip()]
|
|
105
|
+
if len(parts) == 2 and parts[0] in cox_df.columns and parts[1] in cox_df.columns:
|
|
106
|
+
v1, v2 = parts[0], parts[1]
|
|
107
|
+
x1 = pd.get_dummies(cox_df[[v1]], drop_first=True).astype(float)
|
|
108
|
+
x2 = pd.get_dummies(cox_df[[v2]], drop_first=True).astype(float)
|
|
109
|
+
for c1 in x1.columns:
|
|
110
|
+
for c2 in x2.columns:
|
|
111
|
+
X[f"{c1}:{c2}"] = x1[c1] * x2[c2]
|
|
112
|
+
|
|
113
|
+
X_cph = X.copy()
|
|
114
|
+
X_cph["__time__"] = cox_df[time_col].astype(float).values
|
|
115
|
+
X_cph["__event__"] = y_event.values
|
|
116
|
+
|
|
117
|
+
cph = CoxPHFitter()
|
|
118
|
+
fit_ok = True
|
|
119
|
+
ph_p_min = 1.0
|
|
120
|
+
try:
|
|
121
|
+
cph.fit(X_cph, duration_col="__time__", event_col="__event__")
|
|
122
|
+
max_se = float(cph.standard_errors_.max())
|
|
123
|
+
try:
|
|
124
|
+
ph_test = proportional_hazard_test(cph, X_cph, time_transform="rank")
|
|
125
|
+
ph_p_min = float(ph_test.p_values.min()) if hasattr(ph_test, "p_values") else 1.0
|
|
126
|
+
except Exception:
|
|
127
|
+
ph_p_min = 1.0
|
|
128
|
+
except Exception:
|
|
129
|
+
fit_ok = False
|
|
130
|
+
max_se = 999.0
|
|
131
|
+
|
|
132
|
+
coefficients = []
|
|
133
|
+
if fit_ok:
|
|
134
|
+
for var in cph.summary.index:
|
|
135
|
+
beta = cph.params_[var]
|
|
136
|
+
se = cph.standard_errors_[var]
|
|
137
|
+
hr = float(np.exp(np.clip(beta, -700, 700)))
|
|
138
|
+
exp_lo = np.clip(beta - 1.96 * se, -700, 700)
|
|
139
|
+
exp_hi = np.clip(beta + 1.96 * se, -700, 700)
|
|
140
|
+
ci_lo = float(np.exp(exp_lo))
|
|
141
|
+
ci_hi = float(np.exp(exp_hi))
|
|
142
|
+
pval = float(cph.summary.loc[var, "p"])
|
|
143
|
+
coefficients.append({
|
|
144
|
+
"variable": var,
|
|
145
|
+
"adjusted_or": round(hr, 3),
|
|
146
|
+
"adjusted_hr": round(hr, 3),
|
|
147
|
+
"adjusted_ci_95": [round(ci_lo, 3), round(ci_hi, 3)],
|
|
148
|
+
"adjusted_p": round(pval, 4),
|
|
149
|
+
"model_type": "cox_ph",
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
from numpy.linalg import inv
|
|
153
|
+
vif = {}
|
|
154
|
+
if fit_ok:
|
|
155
|
+
corr = X.corr().values
|
|
156
|
+
try:
|
|
157
|
+
inv_corr = inv(corr)
|
|
158
|
+
for i, col in enumerate(X.columns):
|
|
159
|
+
vif[col] = float(inv_corr[i, i])
|
|
160
|
+
except Exception:
|
|
161
|
+
for col in X.columns:
|
|
162
|
+
vif[col] = float("nan")
|
|
163
|
+
|
|
164
|
+
return fit_ok, {
|
|
165
|
+
"model_type": "cox_ph",
|
|
166
|
+
"n_effective": len(cox_df),
|
|
167
|
+
"e_effective": int(y_event.sum()),
|
|
168
|
+
"coefficients": coefficients,
|
|
169
|
+
"max_se": max_se,
|
|
170
|
+
"vif": vif,
|
|
171
|
+
"ph_p_min": ph_p_min,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Logistic Regression path
|
|
175
|
+
try:
|
|
176
|
+
import statsmodels.api as sm
|
|
177
|
+
except ImportError:
|
|
178
|
+
raise HTTPException(501, "statsmodels not installed on this machine.")
|
|
179
|
+
|
|
180
|
+
model_df = df.dropna(subset=predictors + [outcome_col]).copy()
|
|
181
|
+
y = (model_df[outcome_col].astype(str) == str(SESSION.outcome_spec["event_value"])).astype(int)
|
|
182
|
+
X = pd.get_dummies(model_df[predictors], drop_first=True).astype(float)
|
|
183
|
+
|
|
184
|
+
# Add interaction terms if specified
|
|
185
|
+
for inter in interactions:
|
|
186
|
+
term_str = inter.get("term", "")
|
|
187
|
+
parts = [p.strip() for p in term_str.replace("*", ":").split(":") if p.strip()]
|
|
188
|
+
if len(parts) == 2 and parts[0] in model_df.columns and parts[1] in model_df.columns:
|
|
189
|
+
v1, v2 = parts[0], parts[1]
|
|
190
|
+
x1 = pd.get_dummies(model_df[[v1]], drop_first=True).astype(float)
|
|
191
|
+
x2 = pd.get_dummies(model_df[[v2]], drop_first=True).astype(float)
|
|
192
|
+
for c1 in x1.columns:
|
|
193
|
+
for c2 in x2.columns:
|
|
194
|
+
X[f"{c1}:{c2}"] = x1[c1] * x2[c2]
|
|
195
|
+
|
|
196
|
+
X = sm.add_constant(X)
|
|
197
|
+
|
|
198
|
+
fit_ok = True
|
|
199
|
+
try:
|
|
200
|
+
fit = sm.Logit(y, X).fit(disp=0)
|
|
201
|
+
max_se = float(fit.bse.max())
|
|
202
|
+
except Exception:
|
|
203
|
+
fit_ok = False
|
|
204
|
+
fit = None
|
|
205
|
+
max_se = 999.0
|
|
206
|
+
|
|
207
|
+
from numpy.linalg import inv
|
|
208
|
+
vif = {}
|
|
209
|
+
if fit_ok:
|
|
210
|
+
Xc = X.drop(columns=["const"]) if "const" in X.columns else X
|
|
211
|
+
corr = Xc.corr().values
|
|
212
|
+
try:
|
|
213
|
+
inv_corr = inv(corr)
|
|
214
|
+
for i, col in enumerate(Xc.columns):
|
|
215
|
+
vif[col] = float(inv_corr[i, i])
|
|
216
|
+
except np.linalg.LinAlgError:
|
|
217
|
+
for col in Xc.columns:
|
|
218
|
+
vif[col] = float("nan")
|
|
219
|
+
|
|
220
|
+
coefficients = []
|
|
221
|
+
if fit_ok:
|
|
222
|
+
for var in X.columns:
|
|
223
|
+
if var == "const":
|
|
224
|
+
continue
|
|
225
|
+
beta = fit.params[var]
|
|
226
|
+
se = fit.bse[var]
|
|
227
|
+
or_ = float(np.exp(np.clip(beta, -700, 700)))
|
|
228
|
+
exponent_lo = np.clip(beta - 1.96 * se, -700, 700)
|
|
229
|
+
exponent_hi = np.clip(beta + 1.96 * se, -700, 700)
|
|
230
|
+
ci_lo = float(np.exp(exponent_lo))
|
|
231
|
+
ci_hi = float(np.exp(exponent_hi))
|
|
232
|
+
coefficients.append({
|
|
233
|
+
"variable": var,
|
|
234
|
+
"adjusted_or": round(or_, 3),
|
|
235
|
+
"adjusted_ci_95": [round(ci_lo, 3), round(ci_hi, 3)],
|
|
236
|
+
"adjusted_p": round(float(fit.pvalues[var]), 4),
|
|
237
|
+
"model_type": "logistic_regression",
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return fit_ok, {
|
|
241
|
+
"model_type": "logistic_regression",
|
|
242
|
+
"n_effective": len(model_df),
|
|
243
|
+
"e_effective": int(y.sum()),
|
|
244
|
+
"coefficients": coefficients,
|
|
245
|
+
"max_se": max_se,
|
|
246
|
+
"vif": vif,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _run_gates(protocol: dict, fit_ok: bool, results: dict):
|
|
251
|
+
tests = []
|
|
252
|
+
|
|
253
|
+
# Gate 1 — separation & convergence
|
|
254
|
+
if not fit_ok or results["max_se"] > THRESHOLDS["separation_max_se"]:
|
|
255
|
+
tests.append({"test_name": "complete_separation", "status": "FAIL", "metric_value": results["max_se"]})
|
|
256
|
+
else:
|
|
257
|
+
tests.append({"test_name": "complete_separation", "status": "PASS", "metric_value": results["max_se"]})
|
|
258
|
+
|
|
259
|
+
# Gate 2 — VIF
|
|
260
|
+
max_vif = max(results["vif"].values(), default=0.0)
|
|
261
|
+
worst_vars = [k for k, v in results["vif"].items() if v == max_vif] if results["vif"] else []
|
|
262
|
+
if max_vif > THRESHOLDS["vif_fail"]:
|
|
263
|
+
tests.append({"test_name": "multicollinearity_vif", "status": "FAIL", "metric_value": max_vif, "affected_variables": worst_vars})
|
|
264
|
+
elif max_vif >= THRESHOLDS["vif_warning"]:
|
|
265
|
+
tests.append({"test_name": "multicollinearity_vif", "status": "WARNING", "metric_value": max_vif, "affected_variables": worst_vars})
|
|
266
|
+
else:
|
|
267
|
+
tests.append({"test_name": "multicollinearity_vif", "status": "PASS", "metric_value": max_vif})
|
|
268
|
+
|
|
269
|
+
# Gate 3 — proportional hazards
|
|
270
|
+
if results.get("model_type") == "cox_ph":
|
|
271
|
+
ph_p = results.get("ph_p_min", 1.0)
|
|
272
|
+
if ph_p < THRESHOLDS["ph_p_fail"]:
|
|
273
|
+
tests.append({"test_name": "proportional_hazards", "status": "FAIL", "metric_value": round(ph_p, 4)})
|
|
274
|
+
elif ph_p < THRESHOLDS["ph_p_warning"]:
|
|
275
|
+
tests.append({"test_name": "proportional_hazards", "status": "WARNING", "metric_value": round(ph_p, 4)})
|
|
276
|
+
else:
|
|
277
|
+
tests.append({"test_name": "proportional_hazards", "status": "PASS", "metric_value": round(ph_p, 4)})
|
|
278
|
+
elif protocol["outcome_confirmation"].get("time_column"):
|
|
279
|
+
tests.append({"test_name": "proportional_hazards", "status": "PASS", "details": "time column declared"})
|
|
280
|
+
else:
|
|
281
|
+
tests.append({"test_name": "proportional_hazards", "status": "NOT_APPLICABLE", "details": "no time-to-event column declared"})
|
|
282
|
+
|
|
283
|
+
# Gate 4 — linearity
|
|
284
|
+
transforms = protocol.get("pre_specified_transforms", {})
|
|
285
|
+
tests.append({"test_name": "linearity_continuous_terms", "status": "PASS",
|
|
286
|
+
"details": f"NOTE: pre-specified transforms evaluated: {transforms or 'default-linear'}"})
|
|
287
|
+
|
|
288
|
+
# EPV Check — using actual fitted predictor count (k)
|
|
289
|
+
k = len(results.get("coefficients", []))
|
|
290
|
+
e_eff = results.get("e_effective", 0)
|
|
291
|
+
epv = e_eff / max(k, 1) if k > 0 else 0.0
|
|
292
|
+
|
|
293
|
+
if epv < 5.0:
|
|
294
|
+
tests.append({
|
|
295
|
+
"test_name": "events_per_variable_epv",
|
|
296
|
+
"status": "FAIL",
|
|
297
|
+
"metric_value": round(epv, 2),
|
|
298
|
+
"details": f"EPV is {epv:.2f} (< 5.0 threshold) — critical parameter instability and severe overfitting risk.",
|
|
299
|
+
})
|
|
300
|
+
elif epv < 10.0:
|
|
301
|
+
tests.append({
|
|
302
|
+
"test_name": "events_per_variable_epv",
|
|
303
|
+
"status": "WARNING",
|
|
304
|
+
"metric_value": round(epv, 2),
|
|
305
|
+
"details": f"EPV is {epv:.2f} (< 10.0 threshold) — potential parameter instability; interpretations should be cautious.",
|
|
306
|
+
})
|
|
307
|
+
else:
|
|
308
|
+
tests.append({
|
|
309
|
+
"test_name": "events_per_variable_epv",
|
|
310
|
+
"status": "PASS",
|
|
311
|
+
"metric_value": round(epv, 2),
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
statuses = {t["status"] for t in tests}
|
|
315
|
+
overall = "FAIL" if "FAIL" in statuses else ("WARNING" if "WARNING" in statuses else "PASS")
|
|
316
|
+
return overall, tests
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _e_value_calc(est: float) -> float:
|
|
320
|
+
"""Computes E-value for a single risk ratio / odds ratio / hazard ratio estimate."""
|
|
321
|
+
if est <= 0 or pd.isna(est):
|
|
322
|
+
return 1.0
|
|
323
|
+
rr = est if est >= 1.0 else 1.0 / est
|
|
324
|
+
return round(rr + math.sqrt(rr * (rr - 1.0)), 3) if rr > 1.0 else 1.0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _e_value_dual(est: float, ci_lo: float, ci_hi: float) -> dict:
|
|
328
|
+
"""
|
|
329
|
+
Computes point-estimate E-value and CI limit E-value per VanderWeele & Ding (2017).
|
|
330
|
+
If 95% CI includes 1.0 (ci_lo <= 1.0 <= ci_hi), E-value for CI limit is 1.000.
|
|
331
|
+
Otherwise, computes E-value for the bound closest to 1.0.
|
|
332
|
+
"""
|
|
333
|
+
e_est = _e_value_calc(est)
|
|
334
|
+
if ci_lo <= 1.0 <= ci_hi:
|
|
335
|
+
e_ci = 1.000
|
|
336
|
+
else:
|
|
337
|
+
closest_bound = ci_hi if est < 1.0 else ci_lo
|
|
338
|
+
e_ci = _e_value_calc(closest_bound)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"e_value": e_est,
|
|
342
|
+
"e_value_ci": e_ci,
|
|
343
|
+
"formatted": f"{e_est:.3f} (CI bound: {e_ci:.3f})"
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _e_value(or_estimate: float) -> float:
|
|
348
|
+
return _e_value_calc(or_estimate)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@router.post("/run")
|
|
352
|
+
def run_execution():
|
|
353
|
+
_require_h1()
|
|
354
|
+
plan = SESSION.h1_payload
|
|
355
|
+
protocol = plan["protocol"]
|
|
356
|
+
|
|
357
|
+
fit_ok, results = _fit_model(protocol)
|
|
358
|
+
overall, tests = _run_gates(protocol, fit_ok, results)
|
|
359
|
+
if not fit_ok or results.get("max_se", 0.0) > THRESHOLDS["separation_max_se"]:
|
|
360
|
+
overall = "FAIL"
|
|
361
|
+
|
|
362
|
+
plan_hash = plan["provenance"]["plan_fingerprint_h1"]
|
|
363
|
+
|
|
364
|
+
if overall == "FAIL":
|
|
365
|
+
failed = next(t for t in tests if t["status"] == "FAIL")
|
|
366
|
+
gate_name = failed["test_name"]
|
|
367
|
+
SESSION.pending_amendment = {
|
|
368
|
+
"parent_plan_hash": plan_hash,
|
|
369
|
+
"failed_gate": gate_name,
|
|
370
|
+
"evidence": failed,
|
|
371
|
+
"remediation_options": REMEDIATION_OPTIONS.get(gate_name, []),
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
"route": "AUTOPSY_CANVAS",
|
|
375
|
+
"diagnostics_summary": {"overall_status": "FAIL", "tests": tests},
|
|
376
|
+
"failed_gate": gate_name,
|
|
377
|
+
"implicated_variables": failed.get("affected_variables", []),
|
|
378
|
+
"evidence": failed,
|
|
379
|
+
"remediation_options": REMEDIATION_OPTIONS.get(gate_name, []),
|
|
380
|
+
"next_step": "POST /api/execute/amendment/prepare with a chosen remediation + rationale, "
|
|
381
|
+
"then return to Tab 2 (amendment mode) to edit Steps C/D only.",
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
e_values = [
|
|
385
|
+
{
|
|
386
|
+
"variable": c["variable"],
|
|
387
|
+
**_e_value_dual(c["adjusted_or"], c["adjusted_ci_95"][0], c["adjusted_ci_95"][1])
|
|
388
|
+
}
|
|
389
|
+
for c in results["coefficients"]
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
payload = {
|
|
393
|
+
"provenance": {
|
|
394
|
+
"payload_fingerprint_h0": SESSION.h0_payload["provenance"]["payload_fingerprint_h0"],
|
|
395
|
+
"parent_plan_hash": plan_hash,
|
|
396
|
+
"execution_timestamp_utc": pd.Timestamp.utcnow().isoformat() + "Z",
|
|
397
|
+
"was_amended": "parent_plan_hash" in plan["provenance"] and "amendment_rationale" in plan["provenance"],
|
|
398
|
+
},
|
|
399
|
+
"diagnostic_config": {"ruleset_version": RULESET_VERSION, "thresholds_locked": THRESHOLDS},
|
|
400
|
+
"diagnostics_summary": {"overall_status": overall, "tests": tests},
|
|
401
|
+
"model_results": {
|
|
402
|
+
"model_type": results.get("model_type", "logistic_regression"),
|
|
403
|
+
"sample_sizes": {
|
|
404
|
+
"n_total": len(SESSION.raw_df),
|
|
405
|
+
"n_effective": results["n_effective"],
|
|
406
|
+
"e_effective": results["e_effective"],
|
|
407
|
+
},
|
|
408
|
+
"coefficients": results["coefficients"],
|
|
409
|
+
},
|
|
410
|
+
"sensitivity_analysis": {"e_values": e_values, "note": "always computed per §6.4"},
|
|
411
|
+
"manuscript_artifacts": {
|
|
412
|
+
"note": "generated by Tab 4 — call /api/report/* to produce these",
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
canonical = json.dumps(payload, sort_keys=True, default=str).encode()
|
|
416
|
+
payload["provenance"]["execution_fingerprint"] = hashlib.sha256(canonical).hexdigest()
|
|
417
|
+
|
|
418
|
+
SESSION.hexec_payload = payload
|
|
419
|
+
return {"route": "PUBLICATION_PACKAGE" if overall == "PASS" else "PUBLICATION_PACKAGE_WITH_LIMITATIONS", **payload}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.post("/amendment/prepare")
|
|
423
|
+
def prepare_amendment(body: AmendmentPrepareIn):
|
|
424
|
+
if not SESSION.pending_amendment:
|
|
425
|
+
raise HTTPException(400, "No pending amendment — nothing failed.")
|
|
426
|
+
SESSION.pending_amendment["chosen_remediation"] = body.chosen_remediation
|
|
427
|
+
SESSION.pending_amendment["rationale"] = body.rationale
|
|
428
|
+
return {
|
|
429
|
+
"amendment_mode": True,
|
|
430
|
+
"parent_plan_hash": SESSION.pending_amendment.get("parent_plan_hash", ""),
|
|
431
|
+
"failed_gate": SESSION.pending_amendment.get("failed_gate", ""),
|
|
432
|
+
"locked_readonly": {
|
|
433
|
+
"exposure": SESSION.exposure,
|
|
434
|
+
"outcome_confirmation": {
|
|
435
|
+
"column_name": SESSION.outcome_spec["column_name"],
|
|
436
|
+
"event_value": SESSION.outcome_spec.get("event_value", 1),
|
|
437
|
+
"censored_value": SESSION.outcome_spec.get("censored_value", 0),
|
|
438
|
+
"time_column": SESSION.time_column,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
"editable": {
|
|
442
|
+
"confounders": SESSION.confounders,
|
|
443
|
+
"interactions": SESSION.interactions,
|
|
444
|
+
"missing_data_strategy": SESSION.missing_data_strategy,
|
|
445
|
+
},
|
|
446
|
+
"flagged_variables": SESSION.pending_amendment["evidence"].get("affected_variables", []),
|
|
447
|
+
"prefilled_rationale": body.rationale,
|
|
448
|
+
"note": "Outcome stays masked here — only diagnostic evidence shown, never direction/significance (§6.3). "
|
|
449
|
+
"Edit Steps C/D in Tab 2, then /api/plan/lock re-locks chained to this failed plan's hash.",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@router.get("/amendment/state")
|
|
454
|
+
def get_amendment_state():
|
|
455
|
+
if not SESSION.pending_amendment:
|
|
456
|
+
return {"amendment_mode": False}
|
|
457
|
+
return {
|
|
458
|
+
"amendment_mode": True,
|
|
459
|
+
"parent_plan_hash": SESSION.pending_amendment.get("parent_plan_hash", ""),
|
|
460
|
+
"failed_gate": SESSION.pending_amendment.get("failed_gate", ""),
|
|
461
|
+
"locked_readonly": {
|
|
462
|
+
"exposure": SESSION.exposure,
|
|
463
|
+
"outcome_confirmation": {
|
|
464
|
+
"column_name": SESSION.outcome_spec["column_name"] if SESSION.outcome_spec else "",
|
|
465
|
+
"event_value": SESSION.outcome_spec.get("event_value", 1) if SESSION.outcome_spec else 1,
|
|
466
|
+
"censored_value": SESSION.outcome_spec.get("censored_value", 0) if SESSION.outcome_spec else 0,
|
|
467
|
+
"time_column": SESSION.time_column,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
"editable": {
|
|
471
|
+
"confounders": SESSION.confounders,
|
|
472
|
+
"interactions": SESSION.interactions,
|
|
473
|
+
"missing_data_strategy": SESSION.missing_data_strategy,
|
|
474
|
+
},
|
|
475
|
+
"flagged_variables": SESSION.pending_amendment.get("evidence", {}).get("affected_variables", []),
|
|
476
|
+
"prefilled_rationale": SESSION.pending_amendment.get("rationale", ""),
|
|
477
|
+
"chosen_remediation": SESSION.pending_amendment.get("chosen_remediation", ""),
|
|
478
|
+
}
|