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,518 @@
|
|
|
1
|
+
"""Statistical test selector — suggests appropriate tests based on variable types.
|
|
2
|
+
|
|
3
|
+
This module SUGGESTS only. It never auto-picks a test based on outcome data.
|
|
4
|
+
Distribution checks that inform test selection must happen pre-lock on baseline
|
|
5
|
+
data or be declared explicitly in the study plan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
from core.database import get_connection, DATA_ROOT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── AssumptionCheck interface ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AssumptionCheck(ABC):
|
|
18
|
+
"""Pluggable pre-lock assumption check for a statistical test type.
|
|
19
|
+
|
|
20
|
+
Subclasses implement ``applies_to`` (which test names they handle) and
|
|
21
|
+
``check`` (which produces warnings by querying only marginal / independently-
|
|
22
|
+
derivable quantities — never a cross-tabulation of outcome × group before
|
|
23
|
+
the plan is locked).
|
|
24
|
+
|
|
25
|
+
Warning messages must never name specific outcome category values or group
|
|
26
|
+
labels — only variable names, counts, and dimensions.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def applies_to(self, test_name: str) -> bool:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def check(
|
|
35
|
+
self, study_id: str, test: dict, group_col: str, conn, var_info: dict
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Registry of all registered checks ──────────────────────────────────
|
|
41
|
+
# Each AssumptionCheck is instantiated once at module level. The dispatcher
|
|
42
|
+
# (check_assumptions) iterates this list for every declared test.
|
|
43
|
+
_CHECKERS: list[AssumptionCheck] = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _register(checker: type[AssumptionCheck]) -> type[AssumptionCheck]:
|
|
47
|
+
_CHECKERS.append(checker())
|
|
48
|
+
return checker
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Test suggestion helpers ────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def suggested_tests(
|
|
55
|
+
primary_outcome_data_type: str,
|
|
56
|
+
study_type: str,
|
|
57
|
+
n_comparison_groups: int = 2,
|
|
58
|
+
is_paired: bool = False,
|
|
59
|
+
n_total: int | None = None,
|
|
60
|
+
) -> list[dict]:
|
|
61
|
+
"""Return a list of appropriate statistical tests given the variable types."""
|
|
62
|
+
if primary_outcome_data_type == "categorical":
|
|
63
|
+
return _categorical_tests(n_comparison_groups, is_paired, n_total)
|
|
64
|
+
elif primary_outcome_data_type == "continuous":
|
|
65
|
+
return _continuous_tests(n_comparison_groups, is_paired)
|
|
66
|
+
elif primary_outcome_data_type == "time_to_event":
|
|
67
|
+
return _survival_tests(n_comparison_groups)
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _categorical_tests(n_groups: int, paired: bool, n_total: int | None = None) -> list[dict]:
|
|
72
|
+
if n_total is not None and n_total < 30:
|
|
73
|
+
tests = [
|
|
74
|
+
{
|
|
75
|
+
"test_name": "fishers_exact",
|
|
76
|
+
"rationale": "Exact test for 2x2 tables when sample sizes are small.",
|
|
77
|
+
"assumptions": "None.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"test_name": "chi_square",
|
|
81
|
+
"rationale": "Tests association between categorical variables.",
|
|
82
|
+
"assumptions": "Expected frequency >=5 in each cell. May be unreliable with small samples.",
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
else:
|
|
86
|
+
tests = [
|
|
87
|
+
{
|
|
88
|
+
"test_name": "chi_square",
|
|
89
|
+
"rationale": "Tests association between categorical variables.",
|
|
90
|
+
"assumptions": "Expected frequency >=5 in each cell.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"test_name": "fishers_exact",
|
|
94
|
+
"rationale": "Exact test for 2x2 tables when sample sizes are small.",
|
|
95
|
+
"assumptions": "None.",
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
if n_groups == 2 and paired:
|
|
99
|
+
tests.append({
|
|
100
|
+
"test_name": "mcnemar",
|
|
101
|
+
"rationale": "Paired categorical data (before/after, matched pairs).",
|
|
102
|
+
"assumptions": "Binary outcome, discordant pairs.",
|
|
103
|
+
})
|
|
104
|
+
return tests
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _continuous_tests(n_groups: int, paired: bool) -> list[dict]:
|
|
108
|
+
tests = [
|
|
109
|
+
{
|
|
110
|
+
"test_name": "t_test" if n_groups == 2 else "anova",
|
|
111
|
+
"rationale": "Compares means across groups. Parametric.",
|
|
112
|
+
"assumptions": "Normality and homogeneity of variance.",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"test_name": "mann_whitney_u" if n_groups == 2 else "kruskal_wallis",
|
|
116
|
+
"rationale": "Non-parametric comparison of distributions.",
|
|
117
|
+
"assumptions": "None (distribution-free).",
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
if n_groups == 2 and paired:
|
|
121
|
+
tests.extend([
|
|
122
|
+
{
|
|
123
|
+
"test_name": "paired_t_test",
|
|
124
|
+
"rationale": "Paired continuous data. Parametric.",
|
|
125
|
+
"assumptions": "Normality of differences.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"test_name": "wilcoxon_signed_rank",
|
|
129
|
+
"rationale": "Paired continuous data. Non-parametric.",
|
|
130
|
+
"assumptions": "Symmetric distribution of differences.",
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
return tests
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _survival_tests(n_groups: int) -> list[dict]:
|
|
137
|
+
tests = [
|
|
138
|
+
{
|
|
139
|
+
"test_name": "kaplan_meier_logrank",
|
|
140
|
+
"rationale": "Compares survival distributions between groups.",
|
|
141
|
+
"assumptions": "Non-informative censoring, proportional hazards.",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"test_name": "cox_proportional_hazards",
|
|
145
|
+
"rationale": "Multivariable survival analysis with covariates.",
|
|
146
|
+
"assumptions": "Proportional hazards.",
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
return tests
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── Chi-square: sparse expected-cell warning ───────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@_register
|
|
156
|
+
class ChiSquareAssumptionCheck(AssumptionCheck):
|
|
157
|
+
"""Warn when any expected cell count < 5 for a chi-square test.
|
|
158
|
+
|
|
159
|
+
Expected counts are computed from marginal totals only (row counts from
|
|
160
|
+
the shadow table, column counts from the raw group column) — never a
|
|
161
|
+
cross-tabulation of outcome × arm.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def applies_to(self, test_name: str) -> bool:
|
|
165
|
+
return test_name == "chi_square"
|
|
166
|
+
|
|
167
|
+
def check(
|
|
168
|
+
self, study_id: str, test: dict, group_col: str, conn, var_info: dict
|
|
169
|
+
) -> list[str]:
|
|
170
|
+
var_name = test.get("variable_name", "")
|
|
171
|
+
if not var_name:
|
|
172
|
+
return []
|
|
173
|
+
if var_info.get(var_name) != "categorical":
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
shadow_table = f"raw_masked_{study_id}"
|
|
177
|
+
raw_table = f"raw_{study_id}"
|
|
178
|
+
|
|
179
|
+
# Row marginals — outcome category counts from the shadow table
|
|
180
|
+
try:
|
|
181
|
+
cur = conn.execute(
|
|
182
|
+
f'SELECT "{var_name}", COUNT(*) AS cnt FROM {shadow_table} '
|
|
183
|
+
f'WHERE "{var_name}" IS NOT NULL GROUP BY "{var_name}"'
|
|
184
|
+
)
|
|
185
|
+
row_marginals = [r["cnt"] for r in cur.fetchall()]
|
|
186
|
+
except Exception:
|
|
187
|
+
return [
|
|
188
|
+
f"Could not read outcome data for '{var_name}' from shadow table."
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
if not row_marginals:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
# Column marginals — group counts from the raw table
|
|
195
|
+
try:
|
|
196
|
+
cur = conn.execute(
|
|
197
|
+
f'SELECT "{group_col}", COUNT(*) AS cnt FROM {raw_table} '
|
|
198
|
+
f'WHERE "{group_col}" IS NOT NULL GROUP BY "{group_col}"'
|
|
199
|
+
)
|
|
200
|
+
col_marginals = [r["cnt"] for r in cur.fetchall()]
|
|
201
|
+
except Exception:
|
|
202
|
+
return [
|
|
203
|
+
f"Could not read group data for '{group_col}' from raw table."
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if not col_marginals:
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
# Expected counts under independence
|
|
210
|
+
grand = sum(row_marginals)
|
|
211
|
+
min_expected = float("inf")
|
|
212
|
+
for r_cnt in row_marginals:
|
|
213
|
+
for c_cnt in col_marginals:
|
|
214
|
+
exp = r_cnt * c_cnt / grand
|
|
215
|
+
if exp < min_expected:
|
|
216
|
+
min_expected = exp
|
|
217
|
+
|
|
218
|
+
if min_expected < 5:
|
|
219
|
+
r, c = len(row_marginals), len(col_marginals)
|
|
220
|
+
if r == 2 and c == 2:
|
|
221
|
+
alt = "Consider using fishers_exact instead."
|
|
222
|
+
else:
|
|
223
|
+
alt = (
|
|
224
|
+
"Table is not 2×2; fishers_exact is not applicable. "
|
|
225
|
+
"Options: (1) Collapse outcome to binary (e.g., ORR: CR+PR vs rest) "
|
|
226
|
+
"and re-declare with fishers_exact; (2) Descriptive only "
|
|
227
|
+
"(matches small-N oncology practice); "
|
|
228
|
+
"(3) Ordinal test if proportional odds assumption is plausible."
|
|
229
|
+
)
|
|
230
|
+
return [
|
|
231
|
+
f"chi_square on '{var_name}': minimum expected cell count is "
|
|
232
|
+
f"{min_expected:.1f} (below 5 threshold for a "
|
|
233
|
+
f"{r}×{c} table). {alt}"
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# FIXME: Add t-test normality check here.
|
|
240
|
+
# Must check normality using ONLY baseline/masked-permitted data —
|
|
241
|
+
# no outcome values should be cross-tabulated with treatment groups.
|
|
242
|
+
# Relevant test_names: "t_test", "paired_t_test"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@_register
|
|
246
|
+
class TTestAssumptionCheck(AssumptionCheck):
|
|
247
|
+
"""Check normality of pooled outcome distribution for t-test plans.
|
|
248
|
+
|
|
249
|
+
Reads the marginal (ungrouped) outcome values from the shadow table and
|
|
250
|
+
runs Shapiro-Wilk. Warns if n < 30 *and* the normality test suggests
|
|
251
|
+
non-normality (p < 0.05). For n >= 30 the Central Limit Theorem provides
|
|
252
|
+
robustness — no warning regardless of the Shapiro-Wilk result.
|
|
253
|
+
|
|
254
|
+
IMPORTANT: this checks the *pooled* distribution only, never per-group.
|
|
255
|
+
Per-group normality is not checked pre-lock because it would reveal
|
|
256
|
+
structure about the association between the grouping variable and the
|
|
257
|
+
outcome, the same class of problem as the chi-square cross-tab leak.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def applies_to(self, test_name: str) -> bool:
|
|
261
|
+
return test_name in ("t_test", "paired_t_test")
|
|
262
|
+
|
|
263
|
+
def check(
|
|
264
|
+
self, study_id: str, test: dict, group_col: str, conn, var_info: dict
|
|
265
|
+
) -> list[str]:
|
|
266
|
+
var_name = test.get("variable_name", "")
|
|
267
|
+
if not var_name:
|
|
268
|
+
return []
|
|
269
|
+
if var_info.get(var_name) != "continuous":
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
shadow_table = f"raw_masked_{study_id}"
|
|
273
|
+
|
|
274
|
+
# Read marginal (pooled, ungrouped) outcome values from shadow table.
|
|
275
|
+
# No GROUP BY, no reference to group_col — just the raw column values.
|
|
276
|
+
try:
|
|
277
|
+
cur = conn.execute(
|
|
278
|
+
f'SELECT CAST("{var_name}" AS REAL) AS val '
|
|
279
|
+
f'FROM {shadow_table} '
|
|
280
|
+
f'WHERE "{var_name}" IS NOT NULL'
|
|
281
|
+
)
|
|
282
|
+
vals = [r["val"] for r in cur.fetchall() if r["val"] is not None]
|
|
283
|
+
except Exception:
|
|
284
|
+
return [
|
|
285
|
+
f"Could not read outcome data for '{var_name}' from shadow table."
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
if not vals:
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
n = len(vals)
|
|
292
|
+
# CLT robustness for n >= 30 — skip test, no warning
|
|
293
|
+
if n >= 30:
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
from scipy.stats import shapiro as shapiro_test
|
|
297
|
+
try:
|
|
298
|
+
_, p = shapiro_test(vals)
|
|
299
|
+
except Exception:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
if p < 0.05:
|
|
303
|
+
return [
|
|
304
|
+
f"t_test on '{var_name}': pooled distribution may be non-normal "
|
|
305
|
+
f"(Shapiro-Wilk p={p:.4f}, n={n}). "
|
|
306
|
+
f"Consider mann_whitney instead."
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@_register
|
|
313
|
+
class CoxPHAssumptionCheck(AssumptionCheck):
|
|
314
|
+
"""Screen Cox plans using only marginal event information available pre-lock.
|
|
315
|
+
|
|
316
|
+
Computes events-per-variable (EPV) from the pooled event count and declared
|
|
317
|
+
covariates. Warns when EPV < 10 (common rule of thumb).
|
|
318
|
+
|
|
319
|
+
NOTE: The true proportional-hazards assumption (Schoenfeld residuals) cannot
|
|
320
|
+
be checked pre-lock — it requires the fitted Cox model, which needs unmasked,
|
|
321
|
+
grouped outcome data. This EPV check is a feasibility proxy only. The real
|
|
322
|
+
PH check runs post-unmask inside ``_cox_ph()``.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def applies_to(self, test_name: str) -> bool:
|
|
326
|
+
return test_name == "cox_proportional_hazards"
|
|
327
|
+
|
|
328
|
+
def check(self, study_id: str, test: dict, group_col: str, conn, var_info: dict) -> list[str]:
|
|
329
|
+
var_name = test.get("variable_name", "")
|
|
330
|
+
if var_info.get(var_name) != "time_to_event":
|
|
331
|
+
return []
|
|
332
|
+
prefix = var_name.replace("_days", "").replace("_months", "").replace("_time", "")
|
|
333
|
+
event_col = f"{prefix}_event"
|
|
334
|
+
shadow_table = f"raw_masked_{study_id}"
|
|
335
|
+
|
|
336
|
+
# Number of covariates declared in the plan (passed through from cmd_plan)
|
|
337
|
+
n_covariates = max(int(test.get("n_covariates", 0)), 0)
|
|
338
|
+
# +1 for the primary group variable (treatment_arm)
|
|
339
|
+
total_predictors = n_covariates + 1
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
row = conn.execute(
|
|
343
|
+
f'SELECT COUNT("{event_col}") AS n_events '
|
|
344
|
+
f'FROM {shadow_table} '
|
|
345
|
+
f'WHERE CAST("{event_col}" AS INTEGER) = 1'
|
|
346
|
+
).fetchone()
|
|
347
|
+
except Exception:
|
|
348
|
+
return [
|
|
349
|
+
f"cox_proportional_hazards on '{var_name}': "
|
|
350
|
+
"could not count events from shadow table."
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
n_events = int(row["n_events"] or 0)
|
|
354
|
+
if n_events == 0:
|
|
355
|
+
return [
|
|
356
|
+
f"cox_proportional_hazards on '{var_name}': "
|
|
357
|
+
"zero events recorded in pooled data — Cox model cannot converge."
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
epv = n_events / total_predictors if total_predictors > 0 else n_events
|
|
361
|
+
if epv < 10:
|
|
362
|
+
return [
|
|
363
|
+
f"cox_proportional_hazards on '{var_name}': "
|
|
364
|
+
f"{n_events} events across {total_predictors} predictor(s) "
|
|
365
|
+
f"(EPV={epv:.1f}). "
|
|
366
|
+
f"Cox models are unreliable below 10 events per predictor. "
|
|
367
|
+
f"Consider reducing the number of covariates or using a "
|
|
368
|
+
f"simpler analysis."
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def check_cox_ph_model_assumptions(
|
|
375
|
+
study_id: str,
|
|
376
|
+
models: list,
|
|
377
|
+
group_col: str = "treatment_arm",
|
|
378
|
+
) -> list[str]:
|
|
379
|
+
"""Check assumptions for declared multivariable Cox PH models.
|
|
380
|
+
|
|
381
|
+
Parameters
|
|
382
|
+
----------
|
|
383
|
+
study_id : str
|
|
384
|
+
models : list[CoxPHModel] or list[dict]
|
|
385
|
+
List of declared Cox PH models from the study plan.
|
|
386
|
+
group_col : str
|
|
387
|
+
Column name for the comparison groups (e.g. treatment_arm).
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
list[str]
|
|
392
|
+
Warning strings — empty when no assumptions are violated.
|
|
393
|
+
"""
|
|
394
|
+
if not models:
|
|
395
|
+
return []
|
|
396
|
+
|
|
397
|
+
conn = get_connection(study_id)
|
|
398
|
+
|
|
399
|
+
# Pre-fetch variable data types
|
|
400
|
+
cur = conn.execute(
|
|
401
|
+
"SELECT column_name, data_type FROM variables WHERE study_id=?",
|
|
402
|
+
(study_id,),
|
|
403
|
+
)
|
|
404
|
+
var_info = {r["column_name"]: r["data_type"] for r in cur.fetchall()}
|
|
405
|
+
|
|
406
|
+
warnings: list[str] = []
|
|
407
|
+
|
|
408
|
+
for model in models:
|
|
409
|
+
if isinstance(model, dict):
|
|
410
|
+
m = model
|
|
411
|
+
else:
|
|
412
|
+
m = {
|
|
413
|
+
"model_name": model.model_name,
|
|
414
|
+
"survival_time_col": model.survival_time_col,
|
|
415
|
+
"event_col": model.event_col,
|
|
416
|
+
"primary_treatment_col": model.primary_treatment_col,
|
|
417
|
+
"covariate_cols": model.covariate_cols,
|
|
418
|
+
"interaction_terms": list(model.interaction_terms) if hasattr(model, "interaction_terms") else [],
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
survival_time_col = m.get("survival_time_col", "")
|
|
422
|
+
event_col = m.get("event_col", "")
|
|
423
|
+
covariate_cols = m.get("covariate_cols", [])
|
|
424
|
+
interaction_terms = m.get("interaction_terms", [])
|
|
425
|
+
|
|
426
|
+
if not survival_time_col or not event_col:
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
if var_info.get(survival_time_col) != "time_to_event":
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
shadow_table = f"raw_masked_{study_id}"
|
|
433
|
+
|
|
434
|
+
# Total predictors = primary treatment + covariates + interaction terms
|
|
435
|
+
total_predictors = 1 + len(covariate_cols) + len(interaction_terms)
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
row = conn.execute(
|
|
439
|
+
f'SELECT COUNT("{event_col}") AS n_events '
|
|
440
|
+
f'FROM {shadow_table} '
|
|
441
|
+
f'WHERE CAST("{event_col}" AS INTEGER) = 1'
|
|
442
|
+
).fetchone()
|
|
443
|
+
except Exception:
|
|
444
|
+
warnings.append(
|
|
445
|
+
f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
|
|
446
|
+
"could not count events from shadow table."
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
n_events = int(row["n_events"] or 0)
|
|
451
|
+
if n_events == 0:
|
|
452
|
+
warnings.append(
|
|
453
|
+
f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
|
|
454
|
+
"zero events recorded in pooled data — Cox model cannot converge."
|
|
455
|
+
)
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
epv = n_events / total_predictors if total_predictors > 0 else n_events
|
|
459
|
+
if epv < 10:
|
|
460
|
+
warnings.append(
|
|
461
|
+
f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
|
|
462
|
+
f"{n_events} events across {total_predictors} predictor(s) "
|
|
463
|
+
f"(EPV={epv:.1f}). "
|
|
464
|
+
f"Cox models are unreliable below 10 events per predictor. "
|
|
465
|
+
f"Consider reducing the number of covariates or using a "
|
|
466
|
+
f"simpler analysis."
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
conn.close()
|
|
470
|
+
return warnings
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def check_assumptions(
|
|
474
|
+
study_id: str,
|
|
475
|
+
tests: list[dict],
|
|
476
|
+
group_col: str = "treatment_arm",
|
|
477
|
+
) -> list[str]:
|
|
478
|
+
"""Check pre-lock assumptions for each planned test.
|
|
479
|
+
|
|
480
|
+
Iterates the registered :class:`AssumptionCheck` implementations and
|
|
481
|
+
calls each one whose ``applies_to`` matches the test name. All checks
|
|
482
|
+
use only marginal / independently-derivable quantities — never a cross-
|
|
483
|
+
tabulation of outcome × group before the plan is locked.
|
|
484
|
+
|
|
485
|
+
Parameters
|
|
486
|
+
----------
|
|
487
|
+
study_id : str
|
|
488
|
+
tests : list[dict]
|
|
489
|
+
Each dict must have at least ``test_name`` and ``variable_name`` keys.
|
|
490
|
+
group_col : str
|
|
491
|
+
Column name for the comparison groups (e.g. treatment_arm).
|
|
492
|
+
|
|
493
|
+
Returns
|
|
494
|
+
-------
|
|
495
|
+
list[str]
|
|
496
|
+
Warning strings — empty when no assumptions are violated.
|
|
497
|
+
"""
|
|
498
|
+
conn = get_connection(study_id)
|
|
499
|
+
|
|
500
|
+
# Pre-fetch variable data types
|
|
501
|
+
cur = conn.execute(
|
|
502
|
+
"SELECT column_name, data_type FROM variables WHERE study_id=?",
|
|
503
|
+
(study_id,),
|
|
504
|
+
)
|
|
505
|
+
var_info = {r["column_name"]: r["data_type"] for r in cur.fetchall()}
|
|
506
|
+
|
|
507
|
+
warnings: list[str] = []
|
|
508
|
+
|
|
509
|
+
for test in tests:
|
|
510
|
+
test_name = test.get("test_name", "")
|
|
511
|
+
for checker in _CHECKERS:
|
|
512
|
+
if checker.applies_to(test_name):
|
|
513
|
+
warnings.extend(
|
|
514
|
+
checker.check(study_id, test, group_col, conn, var_info)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
conn.close()
|
|
518
|
+
return warnings
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Canonical hashing utilities for data integrity verification.
|
|
2
|
+
|
|
3
|
+
Single source of truth for raw-data hashing across cmd_export, bundle, and
|
|
4
|
+
excel_export. Fixes M2: data hash inconsistency between code paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from core.database import get_connection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sha256(content: str | bytes) -> str:
|
|
16
|
+
"""SHA-256 hex digest of a string or bytes object."""
|
|
17
|
+
if isinstance(content, str):
|
|
18
|
+
content = content.encode("utf-8")
|
|
19
|
+
return hashlib.sha256(content).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def canonical_json(obj) -> str:
|
|
23
|
+
"""Deterministic JSON serialization: sorted keys, no extra whitespace."""
|
|
24
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_raw_data_hash(study_id: str) -> str:
|
|
28
|
+
"""Compute SHA-256 hash of a study's raw data using canonical JSON.
|
|
29
|
+
|
|
30
|
+
Reads from the raw_<study_id> ingestion table, exports all rows as a
|
|
31
|
+
sorted-keys JSON array, and returns the hex digest.
|
|
32
|
+
"""
|
|
33
|
+
conn = get_connection(study_id)
|
|
34
|
+
raw_table = f"raw_{study_id}"
|
|
35
|
+
cur = conn.execute(f"SELECT * FROM {raw_table} ORDER BY row_id")
|
|
36
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
37
|
+
conn.close()
|
|
38
|
+
return sha256(canonical_json(rows))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Provenance tracker — maps every computed statistic to its source lineage.
|
|
2
|
+
|
|
3
|
+
Given a statistic (test_name, variable_ids), returns:
|
|
4
|
+
- source row IDs from raw data that contributed
|
|
5
|
+
- column names used
|
|
6
|
+
- the exact function name and parameters
|
|
7
|
+
- timestamp
|
|
8
|
+
|
|
9
|
+
Stored as JSON per analysis run. Queryable by statistic name or variable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, asdict, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from core.database import DATA_ROOT
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ProvenanceEntry:
|
|
23
|
+
function_name: str
|
|
24
|
+
parameters: dict
|
|
25
|
+
source_row_ids: list[int]
|
|
26
|
+
column_names: list[str]
|
|
27
|
+
test_name: str
|
|
28
|
+
result_id: str
|
|
29
|
+
study_id: str
|
|
30
|
+
computed_at: str = ""
|
|
31
|
+
is_pre_registered: bool = True
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if not self.computed_at:
|
|
35
|
+
self.computed_at = datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProvenanceTracker:
|
|
39
|
+
"""Tracks provenance for a single study.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
tracker = ProvenanceTracker("study_id")
|
|
43
|
+
entry = ProvenanceEntry(...)
|
|
44
|
+
tracker.record(entry)
|
|
45
|
+
lineage = tracker.get_lineage(test_name="chi_square")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, study_id: str):
|
|
49
|
+
self.study_id = study_id
|
|
50
|
+
self._entries: list[ProvenanceEntry] = []
|
|
51
|
+
self._load()
|
|
52
|
+
|
|
53
|
+
def _path(self) -> Path:
|
|
54
|
+
return DATA_ROOT / self.study_id / "provenance.json"
|
|
55
|
+
|
|
56
|
+
def _load(self):
|
|
57
|
+
p = self._path()
|
|
58
|
+
if p.exists():
|
|
59
|
+
data = json.loads(p.read_text())
|
|
60
|
+
self._entries = [ProvenanceEntry(**e) for e in data]
|
|
61
|
+
|
|
62
|
+
def _save(self):
|
|
63
|
+
self._path().write_text(
|
|
64
|
+
json.dumps([asdict(e) for e in self._entries], indent=2)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def record(self, entry: ProvenanceEntry):
|
|
68
|
+
self._entries.append(entry)
|
|
69
|
+
self._save()
|
|
70
|
+
|
|
71
|
+
def record_run(
|
|
72
|
+
self,
|
|
73
|
+
function_name: str,
|
|
74
|
+
parameters: dict,
|
|
75
|
+
source_row_ids: list[int],
|
|
76
|
+
column_names: list[str],
|
|
77
|
+
test_name: str,
|
|
78
|
+
result_id: str,
|
|
79
|
+
is_pre_registered: bool = True,
|
|
80
|
+
):
|
|
81
|
+
entry = ProvenanceEntry(
|
|
82
|
+
function_name=function_name,
|
|
83
|
+
parameters=parameters,
|
|
84
|
+
source_row_ids=source_row_ids,
|
|
85
|
+
column_names=column_names,
|
|
86
|
+
test_name=test_name,
|
|
87
|
+
result_id=result_id,
|
|
88
|
+
study_id=self.study_id,
|
|
89
|
+
is_pre_registered=is_pre_registered,
|
|
90
|
+
)
|
|
91
|
+
self.record(entry)
|
|
92
|
+
|
|
93
|
+
def get_lineage(
|
|
94
|
+
self,
|
|
95
|
+
test_name: str | None = None,
|
|
96
|
+
variable_id: int | None = None,
|
|
97
|
+
) -> list[ProvenanceEntry]:
|
|
98
|
+
"""Return matching provenance entries."""
|
|
99
|
+
results = self._entries
|
|
100
|
+
if test_name:
|
|
101
|
+
results = [e for e in results if e.test_name == test_name]
|
|
102
|
+
return results
|
|
103
|
+
|
|
104
|
+
def get_all(self) -> list[ProvenanceEntry]:
|
|
105
|
+
return list(self._entries)
|