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,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tab 1 — Data & Schema (Blinded Inspection).
|
|
3
|
+
Maps to core.ingestion / core.masking.blind per DECISIONS.md tab-to-CLI mapping.
|
|
4
|
+
|
|
5
|
+
Read-only inspection only. No drop/impute/interaction here (§3). Outcome column,
|
|
6
|
+
once declared, is vaulted from variable browser / redundancy / cross-tabs for the
|
|
7
|
+
rest of Tab 1 — everything below enforces that by filtering it out of responses,
|
|
8
|
+
except the one fixed cohort banner (explicitly allowed, §3).
|
|
9
|
+
|
|
10
|
+
NOTE: dtype inference and correlation/cross-tab logic below are simple pandas
|
|
11
|
+
implementations to get Tab 1 running end-to-end. Swap for the real
|
|
12
|
+
core.ingestion.variable_classifier / core stats calls when wiring the actual
|
|
13
|
+
v2.7.0-core engine in — kept as thin as possible so that swap is a drop-in.
|
|
14
|
+
"""
|
|
15
|
+
import hashlib
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
from itertools import combinations
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from state import SESSION
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------- request schemas ----------
|
|
30
|
+
|
|
31
|
+
class ColumnMapping(BaseModel):
|
|
32
|
+
name: str
|
|
33
|
+
type: str # "identifier" | "numeric_covariate" | "categorical_covariate" | "primary_outcome" | "time_to_event"
|
|
34
|
+
|
|
35
|
+
class SchemaIn(BaseModel):
|
|
36
|
+
column_mappings: list[ColumnMapping]
|
|
37
|
+
|
|
38
|
+
class OutcomeIn(BaseModel):
|
|
39
|
+
column_name: str
|
|
40
|
+
event_value: str
|
|
41
|
+
censored_value: str
|
|
42
|
+
|
|
43
|
+
class SentinelsIn(BaseModel):
|
|
44
|
+
global_na_strings: list[str] = []
|
|
45
|
+
column_overrides: dict[str, list[str]] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------- helpers ----------
|
|
49
|
+
|
|
50
|
+
def _require_data():
|
|
51
|
+
if SESSION.raw_df is None:
|
|
52
|
+
raise HTTPException(400, "No dataset uploaded yet. POST /api/ingest/upload first.")
|
|
53
|
+
|
|
54
|
+
def _outcome_col():
|
|
55
|
+
return SESSION.outcome_spec["column_name"] if SESSION.outcome_vaulted() else None
|
|
56
|
+
|
|
57
|
+
def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
|
|
58
|
+
out = df.copy()
|
|
59
|
+
global_na = SESSION.sentinels.get("global_na_strings", [])
|
|
60
|
+
overrides = SESSION.sentinels.get("column_overrides", {})
|
|
61
|
+
for col in out.columns:
|
|
62
|
+
na_list = global_na + overrides.get(col, [])
|
|
63
|
+
if na_list:
|
|
64
|
+
out[col] = out[col].replace(na_list, pd.NA)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
def _visible_columns() -> list[str]:
|
|
68
|
+
"""Columns minus the vaulted outcome identity."""
|
|
69
|
+
outcome = _outcome_col()
|
|
70
|
+
return [c for c in SESSION.raw_df.columns if c != outcome]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------- endpoints ----------
|
|
74
|
+
|
|
75
|
+
@router.post("/upload")
|
|
76
|
+
async def upload(file: UploadFile = File(...)):
|
|
77
|
+
raw_bytes = await file.read()
|
|
78
|
+
SESSION.dataset_fingerprint = hashlib.sha256(raw_bytes).hexdigest()
|
|
79
|
+
SESSION.raw_path = file.filename
|
|
80
|
+
|
|
81
|
+
if file.filename.lower().endswith((".xlsx", ".xls")):
|
|
82
|
+
df = pd.read_excel(io.BytesIO(raw_bytes))
|
|
83
|
+
else:
|
|
84
|
+
df = pd.read_csv(io.BytesIO(raw_bytes))
|
|
85
|
+
SESSION.raw_df = df
|
|
86
|
+
|
|
87
|
+
# naive dtype guess — placeholder for core.ingestion.variable_classifier
|
|
88
|
+
guessed = []
|
|
89
|
+
for col in df.columns:
|
|
90
|
+
if df[col].nunique() == len(df[col]) and df[col].is_unique:
|
|
91
|
+
t = "identifier"
|
|
92
|
+
elif pd.api.types.is_numeric_dtype(df[col]):
|
|
93
|
+
t = "numeric_covariate"
|
|
94
|
+
else:
|
|
95
|
+
t = "categorical_covariate"
|
|
96
|
+
guessed.append({"name": col, "guessed_type": t})
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"dataset_fingerprint": SESSION.dataset_fingerprint,
|
|
100
|
+
"n_rows": len(df),
|
|
101
|
+
"columns": guessed,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post("/schema")
|
|
106
|
+
def set_schema(body: SchemaIn):
|
|
107
|
+
_require_data()
|
|
108
|
+
mappings = []
|
|
109
|
+
for m in body.column_mappings:
|
|
110
|
+
entry = {"name": m.name, "type": m.type}
|
|
111
|
+
if m.type == "identifier":
|
|
112
|
+
entry["has_duplicates"] = bool(SESSION.raw_df[m.name].duplicated().any())
|
|
113
|
+
mappings.append(entry)
|
|
114
|
+
SESSION.column_mappings = mappings
|
|
115
|
+
return {"column_mappings": mappings}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.post("/outcome")
|
|
119
|
+
def declare_outcome(body: OutcomeIn):
|
|
120
|
+
"""Direct declaration only — never auto-detected (§3). Vaults the column immediately."""
|
|
121
|
+
_require_data()
|
|
122
|
+
if body.column_name not in SESSION.raw_df.columns:
|
|
123
|
+
raise HTTPException(400, f"Column '{body.column_name}' not found.")
|
|
124
|
+
SESSION.outcome_spec = {
|
|
125
|
+
"column_name": body.column_name,
|
|
126
|
+
"event_value": body.event_value,
|
|
127
|
+
"censored_value": body.censored_value,
|
|
128
|
+
"vaulted": True,
|
|
129
|
+
}
|
|
130
|
+
return {"outcome_spec": {"vaulted": True}} # column identity not echoed back either
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.post("/sentinels")
|
|
134
|
+
def set_sentinels(body: SentinelsIn):
|
|
135
|
+
_require_data()
|
|
136
|
+
SESSION.sentinels = {
|
|
137
|
+
"global_na_strings": body.global_na_strings,
|
|
138
|
+
"column_overrides": body.column_overrides,
|
|
139
|
+
}
|
|
140
|
+
return SESSION.sentinels
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/missingness")
|
|
144
|
+
def missingness():
|
|
145
|
+
_require_data()
|
|
146
|
+
df = _apply_sentinels(SESSION.raw_df)
|
|
147
|
+
cols = _visible_columns()
|
|
148
|
+
return {c: round(float(df[c].isna().mean()), 4) for c in cols}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.get("/redundancy")
|
|
152
|
+
def redundancy():
|
|
153
|
+
"""Covariate-covariate correlation only — outcome excluded. |r| > 0.8 flagged (§3)."""
|
|
154
|
+
_require_data()
|
|
155
|
+
df = _apply_sentinels(SESSION.raw_df)
|
|
156
|
+
numeric_cols = [
|
|
157
|
+
c for c in _visible_columns()
|
|
158
|
+
if pd.api.types.is_numeric_dtype(df[c])
|
|
159
|
+
]
|
|
160
|
+
pairs = []
|
|
161
|
+
for a, b in combinations(numeric_cols, 2):
|
|
162
|
+
r = df[[a, b]].corr().iloc[0, 1]
|
|
163
|
+
if pd.notna(r) and abs(r) > 0.8:
|
|
164
|
+
pairs.append({"var1": a, "var2": b, "r": round(float(r), 3)})
|
|
165
|
+
return {"high_correlation_pairs": pairs}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get("/sparse-crosstabs")
|
|
169
|
+
def sparse_crosstabs(min_cell_threshold: int = 5):
|
|
170
|
+
"""Categorical-categorical sparse cell check — outcome excluded."""
|
|
171
|
+
_require_data()
|
|
172
|
+
df = _apply_sentinels(SESSION.raw_df)
|
|
173
|
+
cat_cols = [
|
|
174
|
+
c for c in _visible_columns()
|
|
175
|
+
if not pd.api.types.is_numeric_dtype(df[c])
|
|
176
|
+
]
|
|
177
|
+
flagged = []
|
|
178
|
+
for a, b in combinations(cat_cols, 2):
|
|
179
|
+
ct = pd.crosstab(df[a], df[b])
|
|
180
|
+
if ct.size and ct.values.min() < min_cell_threshold:
|
|
181
|
+
flagged.append({"var1": a, "var2": b, "min_cell_count": int(ct.values.min())})
|
|
182
|
+
return {"sparse_cross_tabs": flagged}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.get("/cohort-banner")
|
|
186
|
+
def cohort_banner():
|
|
187
|
+
"""The one allowed exception: touches outcome once, fixed, not recomputed per action (§3)."""
|
|
188
|
+
_require_data()
|
|
189
|
+
if not SESSION.outcome_vaulted():
|
|
190
|
+
raise HTTPException(400, "Declare outcome first — banner needs event count.")
|
|
191
|
+
df = SESSION.raw_df
|
|
192
|
+
spec = SESSION.outcome_spec
|
|
193
|
+
n_total = len(df)
|
|
194
|
+
e_total = int((df[spec["column_name"]].astype(str) == str(spec["event_value"])).sum())
|
|
195
|
+
return {
|
|
196
|
+
"n_total": n_total,
|
|
197
|
+
"e_total": e_total,
|
|
198
|
+
"event_rate": round(e_total / n_total, 4) if n_total else 0.0,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.post("/lock")
|
|
203
|
+
def lock_stage1():
|
|
204
|
+
"""Emits Stage1Payload (H0) per DECISIONS.md §3. Hash excludes itself, computed then inserted."""
|
|
205
|
+
_require_data()
|
|
206
|
+
if not SESSION.outcome_vaulted():
|
|
207
|
+
raise HTTPException(400, "Cannot lock Stage 1 without a declared outcome.")
|
|
208
|
+
|
|
209
|
+
banner = cohort_banner()
|
|
210
|
+
miss = missingness()
|
|
211
|
+
redund = redundancy()
|
|
212
|
+
sparse = sparse_crosstabs()
|
|
213
|
+
|
|
214
|
+
payload = {
|
|
215
|
+
"provenance": {
|
|
216
|
+
"dataset_fingerprint": SESSION.dataset_fingerprint,
|
|
217
|
+
"ephemeral_raw_path": SESSION.raw_path,
|
|
218
|
+
},
|
|
219
|
+
"cohort_facts": banner,
|
|
220
|
+
"outcome_spec": SESSION.outcome_spec,
|
|
221
|
+
"column_mappings": SESSION.column_mappings,
|
|
222
|
+
"sentinels": SESSION.sentinels,
|
|
223
|
+
"precomputations_advisory_cache": {
|
|
224
|
+
"is_advisory": True,
|
|
225
|
+
"missingness_pct": miss,
|
|
226
|
+
"high_correlation_pairs": redund["high_correlation_pairs"],
|
|
227
|
+
"sparse_cross_tabs": sparse["sparse_cross_tabs"],
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
canonical = json.dumps(payload, sort_keys=True, default=str).encode()
|
|
231
|
+
payload["provenance"]["payload_fingerprint_h0"] = hashlib.sha256(canonical).hexdigest()
|
|
232
|
+
SESSION.h0_payload = payload # Tab 2 chains plan_fingerprint_h1 off this
|
|
233
|
+
return payload
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tab 2 — Socratic Wizard & Lock Gate.
|
|
3
|
+
Maps to core.planning / core.provenance per DECISIONS.md §4-5.
|
|
4
|
+
|
|
5
|
+
Novice wizard and Expert JSON-import both land here structurally — this file only
|
|
6
|
+
implements the wizard steps (entry_mode="socratic_wizard"). Expert direct-spec
|
|
7
|
+
import is a separate endpoint, not built this session.
|
|
8
|
+
|
|
9
|
+
Steps A-D per §4:
|
|
10
|
+
A exposure -> /exposure
|
|
11
|
+
B outcome confirm -> /outcome-confirm (GET, read-only re-display) + /time-column
|
|
12
|
+
C confounders -> /confounders, /interactions (Manuscript Mirror at /manuscript-mirror)
|
|
13
|
+
D rigor / missing -> /missing-strategy, /epv-live (recomputed live, never cached)
|
|
14
|
+
lock -> /lock, emits Stage2Payload (H1), chained to H0 via payload_fingerprint_h0.
|
|
15
|
+
"""
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from fastapi import APIRouter, HTTPException
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from state import SESSION
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------- request schemas ----------
|
|
29
|
+
|
|
30
|
+
class ExposureIn(BaseModel):
|
|
31
|
+
column_name: str
|
|
32
|
+
reference_level: str
|
|
33
|
+
|
|
34
|
+
class TimeColumnIn(BaseModel):
|
|
35
|
+
time_column: str
|
|
36
|
+
|
|
37
|
+
class ConfoundersIn(BaseModel):
|
|
38
|
+
confounders: list[str]
|
|
39
|
+
|
|
40
|
+
class InteractionIn(BaseModel):
|
|
41
|
+
term: str
|
|
42
|
+
rationale: str
|
|
43
|
+
|
|
44
|
+
class MissingStrategyIn(BaseModel):
|
|
45
|
+
global_default: str # "complete_case" | "impute" | "flag_as_missing_category"
|
|
46
|
+
column_overrides: dict[str, str] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------- helpers ----------
|
|
50
|
+
|
|
51
|
+
def _require_h0():
|
|
52
|
+
if SESSION.h0_payload is None:
|
|
53
|
+
raise HTTPException(400, "Lock Stage 1 first — no H0 payload to chain off.")
|
|
54
|
+
|
|
55
|
+
def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
|
|
56
|
+
out = df.copy()
|
|
57
|
+
global_na = SESSION.sentinels.get("global_na_strings", [])
|
|
58
|
+
overrides = SESSION.sentinels.get("column_overrides", {})
|
|
59
|
+
for col in out.columns:
|
|
60
|
+
na_list = global_na + overrides.get(col, [])
|
|
61
|
+
if na_list:
|
|
62
|
+
out[col] = out[col].replace(na_list, pd.NA)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
def _complete_case_columns() -> list[str]:
|
|
66
|
+
"""Columns whose missingness actually costs rows under the current strategy
|
|
67
|
+
impute / flag_as_missing_category columns don't drop rows, complete_case ones do."""
|
|
68
|
+
strat = SESSION.missing_data_strategy
|
|
69
|
+
cols = []
|
|
70
|
+
if SESSION.exposure:
|
|
71
|
+
cols.append(SESSION.exposure["column_name"])
|
|
72
|
+
cols += SESSION.confounders
|
|
73
|
+
for c in cols[:]:
|
|
74
|
+
method = strat["column_overrides"].get(c, strat["global_default"])
|
|
75
|
+
if method != "complete_case":
|
|
76
|
+
cols.remove(c)
|
|
77
|
+
return cols
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------- endpoints ----------
|
|
81
|
+
|
|
82
|
+
@router.post("/exposure")
|
|
83
|
+
def set_exposure(body: ExposureIn):
|
|
84
|
+
_require_h0()
|
|
85
|
+
vaulted_outcome = SESSION.outcome_spec["column_name"]
|
|
86
|
+
if body.column_name == vaulted_outcome:
|
|
87
|
+
raise HTTPException(400, "Exposure can't be the vaulted outcome column.")
|
|
88
|
+
SESSION.exposure = body.dict()
|
|
89
|
+
return SESSION.exposure
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/outcome-confirm")
|
|
93
|
+
def outcome_confirm():
|
|
94
|
+
"""Read-only re-display — never a re-selection. Outcome was fixed in Stage 1."""
|
|
95
|
+
_require_h0()
|
|
96
|
+
spec = SESSION.outcome_spec
|
|
97
|
+
return {
|
|
98
|
+
"column_name": spec["column_name"],
|
|
99
|
+
"event_value": spec["event_value"],
|
|
100
|
+
"censored_value": spec["censored_value"],
|
|
101
|
+
"time_column": SESSION.time_column,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post("/time-column")
|
|
106
|
+
def set_time_column(body: TimeColumnIn):
|
|
107
|
+
_require_h0()
|
|
108
|
+
SESSION.time_column = body.time_column
|
|
109
|
+
return {"time_column": SESSION.time_column}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/confounders")
|
|
113
|
+
def set_confounders(body: ConfoundersIn):
|
|
114
|
+
_require_h0()
|
|
115
|
+
SESSION.confounders = body.confounders
|
|
116
|
+
return {"confounders": SESSION.confounders}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get("/redundancy-warnings")
|
|
120
|
+
def redundancy_warnings():
|
|
121
|
+
"""Surfaces only the pairs relevant to what's actually been selected as confounders."""
|
|
122
|
+
_require_h0()
|
|
123
|
+
selected = set(SESSION.confounders)
|
|
124
|
+
pairs = SESSION.h0_payload["precomputations_advisory_cache"]["high_correlation_pairs"]
|
|
125
|
+
return {"warnings": [p for p in pairs if p["var1"] in selected and p["var2"] in selected]}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.post("/interactions")
|
|
129
|
+
def add_interaction(body: InteractionIn):
|
|
130
|
+
"""Interaction terms permitted only here (§4), gated by rationale sanity floor (§5) —
|
|
131
|
+
~15 char minimum to block literal 'yes'/'none', not a strict quality gate."""
|
|
132
|
+
_require_h0()
|
|
133
|
+
if len(body.rationale.strip()) < 15:
|
|
134
|
+
raise HTTPException(400, "Rationale too short — needs genuine clinical justification, not a placeholder.")
|
|
135
|
+
SESSION.interactions.append(body.dict())
|
|
136
|
+
return {"interactions": SESSION.interactions}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.get("/manuscript-mirror")
|
|
140
|
+
def manuscript_mirror():
|
|
141
|
+
"""Live preview of how each interaction rationale will read in the exported
|
|
142
|
+
Methods / STROBE supplement — the real quality incentive, no keyword gating (§5)."""
|
|
143
|
+
_require_h0()
|
|
144
|
+
lines = []
|
|
145
|
+
for i in SESSION.interactions:
|
|
146
|
+
lines.append(
|
|
147
|
+
f"An interaction term ({i['term']}) was pre-specified. Rationale: {i['rationale']}"
|
|
148
|
+
)
|
|
149
|
+
return {"methods_section_preview": " ".join(lines) or "No interaction terms declared."}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.post("/missing-strategy")
|
|
153
|
+
def set_missing_strategy(body: MissingStrategyIn):
|
|
154
|
+
_require_h0()
|
|
155
|
+
SESSION.missing_data_strategy = body.dict()
|
|
156
|
+
return SESSION.missing_data_strategy
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.get("/epv-live")
|
|
160
|
+
def epv_live():
|
|
161
|
+
"""Recomputed fresh on every call — never a cached/static number (§4)."""
|
|
162
|
+
_require_h0()
|
|
163
|
+
if not SESSION.exposure:
|
|
164
|
+
raise HTTPException(400, "Set exposure first.")
|
|
165
|
+
|
|
166
|
+
df = _apply_sentinels(SESSION.raw_df)
|
|
167
|
+
cc_cols = _complete_case_columns()
|
|
168
|
+
outcome_col = SESSION.outcome_spec["column_name"]
|
|
169
|
+
|
|
170
|
+
subset_cols = cc_cols + [outcome_col]
|
|
171
|
+
effective_df = df.dropna(subset=subset_cols) if cc_cols else df
|
|
172
|
+
|
|
173
|
+
n_effective = len(effective_df)
|
|
174
|
+
e_total = (effective_df[outcome_col].astype(str) == str(SESSION.outcome_spec["event_value"])).sum()
|
|
175
|
+
|
|
176
|
+
# Calculate degrees of freedom (k) for main effects
|
|
177
|
+
predictors = [SESSION.exposure["column_name"]] + SESSION.confounders
|
|
178
|
+
k = 0
|
|
179
|
+
for col in predictors:
|
|
180
|
+
if col in effective_df.columns:
|
|
181
|
+
if pd.api.types.is_numeric_dtype(effective_df[col]):
|
|
182
|
+
k += 1
|
|
183
|
+
else:
|
|
184
|
+
n_uniq = effective_df[col].dropna().nunique()
|
|
185
|
+
k += max(n_uniq - 1, 1)
|
|
186
|
+
else:
|
|
187
|
+
k += 1
|
|
188
|
+
|
|
189
|
+
# Calculate degrees of freedom for interaction terms (DoF_A * DoF_B)
|
|
190
|
+
for inter in SESSION.interactions:
|
|
191
|
+
term_str = inter.get("term", "")
|
|
192
|
+
parts = [p.strip() for p in term_str.replace("*", ":").split(":") if p.strip()]
|
|
193
|
+
if len(parts) >= 2:
|
|
194
|
+
inter_dof = 1
|
|
195
|
+
for p in parts:
|
|
196
|
+
if p in effective_df.columns:
|
|
197
|
+
if not pd.api.types.is_numeric_dtype(effective_df[p]):
|
|
198
|
+
u = effective_df[p].dropna().nunique()
|
|
199
|
+
inter_dof *= max(u - 1, 1)
|
|
200
|
+
k += inter_dof
|
|
201
|
+
else:
|
|
202
|
+
k += 1
|
|
203
|
+
|
|
204
|
+
epv = round(e_total / k, 2) if k else None
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"n_effective": n_effective,
|
|
208
|
+
"e_effective": int(e_total),
|
|
209
|
+
"parameters_k": k,
|
|
210
|
+
"epv": epv,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.get("/amendment-status")
|
|
215
|
+
def amendment_status():
|
|
216
|
+
return {"pending": SESSION.pending_amendment}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.post("/lock")
|
|
220
|
+
def lock_stage2():
|
|
221
|
+
"""Emits Stage2Payload (H1), chained off H0's payload_fingerprint_h0."""
|
|
222
|
+
_require_h0()
|
|
223
|
+
if not SESSION.exposure:
|
|
224
|
+
raise HTTPException(400, "Exposure not set — Step A incomplete.")
|
|
225
|
+
|
|
226
|
+
feasibility = epv_live()
|
|
227
|
+
if feasibility["epv"] is not None and feasibility["epv"] < 10:
|
|
228
|
+
# not a hard block by design choice here — surfaced, not silently allowed either.
|
|
229
|
+
# (DECISIONS.md doesn't mandate a hard EPV floor at lock; flag only.)
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
payload = {
|
|
233
|
+
"provenance": {
|
|
234
|
+
"payload_fingerprint_h0": SESSION.h0_payload["provenance"]["payload_fingerprint_h0"],
|
|
235
|
+
"entry_mode": "socratic_wizard",
|
|
236
|
+
},
|
|
237
|
+
"protocol": {
|
|
238
|
+
"study_design": "retrospective_cohort",
|
|
239
|
+
"exposure": SESSION.exposure,
|
|
240
|
+
"outcome_confirmation": {
|
|
241
|
+
"column_name": SESSION.outcome_spec["column_name"],
|
|
242
|
+
"event_value": SESSION.outcome_spec["event_value"],
|
|
243
|
+
"time_column": SESSION.time_column,
|
|
244
|
+
},
|
|
245
|
+
"confounders": SESSION.confounders,
|
|
246
|
+
"interactions": SESSION.interactions,
|
|
247
|
+
"missing_data_strategy": SESSION.missing_data_strategy,
|
|
248
|
+
"pre_specified_transforms": {}, # NOTE: assumes linear for all continuous covariates
|
|
249
|
+
# until Tab2 UI grows a transform picker (Gate 4 reads this)
|
|
250
|
+
},
|
|
251
|
+
"locked_feasibility_metrics": feasibility,
|
|
252
|
+
}
|
|
253
|
+
# amendment chaining: if this lock follows a Tab3 FAIL, parent is the failed plan's hash,
|
|
254
|
+
# not H0 directly — new plan hash still gets computed fresh below.
|
|
255
|
+
if SESSION.pending_amendment:
|
|
256
|
+
payload["provenance"]["parent_plan_hash"] = SESSION.pending_amendment["parent_plan_hash"]
|
|
257
|
+
payload["provenance"]["amendment_rationale"] = SESSION.pending_amendment.get("rationale", "")
|
|
258
|
+
|
|
259
|
+
canonical = json.dumps(payload, sort_keys=True, default=str).encode()
|
|
260
|
+
payload["provenance"]["plan_fingerprint_h1"] = hashlib.sha256(canonical).hexdigest()
|
|
261
|
+
|
|
262
|
+
SESSION.h1_payload = payload
|
|
263
|
+
SESSION.plan_chain.append(payload)
|
|
264
|
+
SESSION.pending_amendment = None
|
|
265
|
+
return payload
|