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,380 @@
|
|
|
1
|
+
"""Tests for inferential statistics."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
from lifelines.exceptions import ConvergenceWarning
|
|
7
|
+
|
|
8
|
+
from core.stats.inferential import run_test
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_df():
|
|
12
|
+
"""Simple two-group data."""
|
|
13
|
+
return pd.DataFrame({
|
|
14
|
+
"group": ["A"] * 20 + ["B"] * 20,
|
|
15
|
+
"outcome": [1] * 12 + [0] * 8 + [1] * 6 + [0] * 14,
|
|
16
|
+
"continuous": [65 + i for i in range(20)] + [70 + i for i in range(20)],
|
|
17
|
+
"time": [100 + i * 10 for i in range(40)],
|
|
18
|
+
"event": [1 if i < 10 else 0 for i in range(20)] + [1 if i < 15 else 0 for i in range(20)],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_chi_square():
|
|
23
|
+
df = _make_df()
|
|
24
|
+
result = run_test("chi_square", df, "outcome", "group")
|
|
25
|
+
assert result["test_name"] == "chi_square"
|
|
26
|
+
assert result["statistic"] is not None
|
|
27
|
+
assert result["p_value"] is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_fishers_exact():
|
|
31
|
+
df = _make_df()
|
|
32
|
+
result = run_test("fishers_exact", df, "outcome", "group")
|
|
33
|
+
assert result["test_name"] == "fishers_exact"
|
|
34
|
+
assert result["statistic"] is not None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_t_test():
|
|
38
|
+
df = _make_df()
|
|
39
|
+
result = run_test("t_test", df, "outcome", "group")
|
|
40
|
+
assert result["test_name"] == "t_test"
|
|
41
|
+
assert result["statistic"] is not None
|
|
42
|
+
assert result["ci_lower"] is not None
|
|
43
|
+
assert result["ci_upper"] is not None
|
|
44
|
+
assert result["ci_lower"] < result["ci_upper"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_mann_whitney():
|
|
48
|
+
df = _make_df()
|
|
49
|
+
result = run_test("mann_whitney_u", df, "continuous", "group")
|
|
50
|
+
assert result["test_name"] == "mann_whitney_u"
|
|
51
|
+
assert result["statistic"] is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_anova():
|
|
55
|
+
df = pd.DataFrame({
|
|
56
|
+
"group": ["A"] * 10 + ["B"] * 10 + ["C"] * 10,
|
|
57
|
+
"val": [60 + i for i in range(10)] + [70 + i for i in range(10)] + [80 + i for i in range(10)],
|
|
58
|
+
})
|
|
59
|
+
result = run_test("anova", df, "val", "group")
|
|
60
|
+
assert result["test_name"] == "anova"
|
|
61
|
+
assert result["statistic"] is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_kruskal_wallis():
|
|
65
|
+
df = pd.DataFrame({
|
|
66
|
+
"group": ["A"] * 10 + ["B"] * 10 + ["C"] * 10,
|
|
67
|
+
"val": [60 + i for i in range(10)] + [70 + i for i in range(10)] + [80 + i for i in range(10)],
|
|
68
|
+
})
|
|
69
|
+
result = run_test("kruskal_wallis", df, "val", "group")
|
|
70
|
+
assert result["test_name"] == "kruskal_wallis"
|
|
71
|
+
assert result["statistic"] is not None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_kaplan_meier_logrank():
|
|
75
|
+
df = _make_df()
|
|
76
|
+
result = run_test("kaplan_meier_logrank", df, "outcome",
|
|
77
|
+
"group", time_col="time", event_col="event")
|
|
78
|
+
assert result["test_name"] == "kaplan_meier_logrank"
|
|
79
|
+
assert result["statistic"] is not None
|
|
80
|
+
assert result["p_value"] is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cox_ph():
|
|
84
|
+
df = _make_df()
|
|
85
|
+
result = run_test("cox_proportional_hazards", df, "outcome",
|
|
86
|
+
"group", time_col="time", event_col="event",
|
|
87
|
+
covariates=["continuous"])
|
|
88
|
+
assert result["test_name"] == "cox_proportional_hazards"
|
|
89
|
+
# May or may not converge on tiny data, but should not crash
|
|
90
|
+
assert result is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_unknown_test():
|
|
94
|
+
df = _make_df()
|
|
95
|
+
result = run_test("imaginary_test", df, "outcome", "group")
|
|
96
|
+
assert "error" in str(result.get("params", {}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_kaplan_meier_rejects_missing_event_col():
|
|
100
|
+
"""Survival test on a duration-only column must raise a clear ValueError."""
|
|
101
|
+
df = pd.DataFrame({
|
|
102
|
+
"pfs_days": [100, 200, 150],
|
|
103
|
+
"treatment_arm": ["A", "B", "A"],
|
|
104
|
+
})
|
|
105
|
+
with pytest.raises(ValueError, match="no linked event/censoring column"):
|
|
106
|
+
run_test("kaplan_meier_logrank", df, outcome_col="pfs_days",
|
|
107
|
+
group_col="treatment_arm", time_col="pfs_days",
|
|
108
|
+
event_col="pfs_event")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_kaplan_meier_rejects_none_event_col():
|
|
112
|
+
"""Survival test with event_col=None must raise a clear ValueError."""
|
|
113
|
+
df = pd.DataFrame({
|
|
114
|
+
"pfs_days": [100, 200, 150],
|
|
115
|
+
"treatment_arm": ["A", "B", "A"],
|
|
116
|
+
})
|
|
117
|
+
with pytest.raises(ValueError, match="no linked event/censoring column"):
|
|
118
|
+
run_test("kaplan_meier_logrank", df, outcome_col="pfs_days",
|
|
119
|
+
group_col="treatment_arm", time_col="pfs_days",
|
|
120
|
+
event_col=None)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_cox_ph_rejects_missing_event_col():
|
|
124
|
+
"""Cox PH on a duration-only column must raise a clear ValueError."""
|
|
125
|
+
df = pd.DataFrame({
|
|
126
|
+
"pfs_days": [100, 200, 150],
|
|
127
|
+
"treatment_arm": ["A", "B", "A"],
|
|
128
|
+
})
|
|
129
|
+
with pytest.raises(ValueError, match="no linked event/censoring column"):
|
|
130
|
+
run_test("cox_proportional_hazards", df, outcome_col="pfs_days",
|
|
131
|
+
group_col="treatment_arm", time_col="pfs_days",
|
|
132
|
+
event_col="pfs_event", covariates=[])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
|
|
136
|
+
@pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
|
|
137
|
+
def test_cox_ph_model_treats_string_stored_numeric_as_continuous():
|
|
138
|
+
"""String-dtype column (simulating SQLite TEXT ingest) with 'continuous'
|
|
139
|
+
in var_types must be converted to numeric, not treated as categorical.
|
|
140
|
+
|
|
141
|
+
Without var_types, a string 'age' column produces one lifelines dummy
|
|
142
|
+
per unique value (age[T.45], age[T.70], ...), causing singular Hessian.
|
|
143
|
+
With var_types={'age': 'continuous'}, it becomes a single coefficient.
|
|
144
|
+
"""
|
|
145
|
+
df = pd.DataFrame({
|
|
146
|
+
"time": ["100", "200", "150", "300"],
|
|
147
|
+
"event": ["1", "0", "1", "0"],
|
|
148
|
+
"arm": ["A", "A", "B", "B"],
|
|
149
|
+
"age": ["45", "70", "55", "60"],
|
|
150
|
+
})
|
|
151
|
+
var_types = {"age": "continuous", "arm": "categorical",
|
|
152
|
+
"time": "time_to_event", "event": "categorical"}
|
|
153
|
+
|
|
154
|
+
result = run_test(
|
|
155
|
+
"cox_ph_model", df,
|
|
156
|
+
outcome_col="time", group_col="arm",
|
|
157
|
+
time_col="time", event_col="event",
|
|
158
|
+
covariates=["age"],
|
|
159
|
+
var_types=var_types,
|
|
160
|
+
)
|
|
161
|
+
# Should converge with a single age coefficient (HR > 0)
|
|
162
|
+
assert result["statistic"] is not None, "HR must not be None"
|
|
163
|
+
assert result["statistic"] > 0, "HR must be positive"
|
|
164
|
+
assert result["p_value"] is not None
|
|
165
|
+
|
|
166
|
+
# Per-covariate results: age should appear as one row (no per-value dummies)
|
|
167
|
+
cov_results = result.get("params", {}).get("per_covariate_results", [])
|
|
168
|
+
age_rows = [r for r in cov_results if r["covariate"] == "age"]
|
|
169
|
+
assert len(age_rows) == 1, (
|
|
170
|
+
f"Expected 1 coefficient for 'age', got {len(age_rows)} — "
|
|
171
|
+
f"variable was likely treated as categorical: {cov_results}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
|
|
176
|
+
@pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
|
|
177
|
+
def test_cox_ph_model_without_var_types_falls_back_to_dtype():
|
|
178
|
+
"""When var_types is None, fall back to pandas dtype check."""
|
|
179
|
+
df = pd.DataFrame({
|
|
180
|
+
"time": [100.0, 200.0, 150.0, 300.0],
|
|
181
|
+
"event": [1, 0, 1, 0],
|
|
182
|
+
"arm": ["A", "A", "B", "B"],
|
|
183
|
+
"age": [45.0, 70.0, 55.0, 60.0],
|
|
184
|
+
})
|
|
185
|
+
# Numeric dtypes should be detected as numeric even without var_types
|
|
186
|
+
result = run_test(
|
|
187
|
+
"cox_ph_model", df,
|
|
188
|
+
outcome_col="time", group_col="arm",
|
|
189
|
+
time_col="time", event_col="event",
|
|
190
|
+
covariates=["age"],
|
|
191
|
+
)
|
|
192
|
+
assert result["statistic"] is not None
|
|
193
|
+
assert result["statistic"] > 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_cox_ph_model_returns_hr_scale_ci():
|
|
197
|
+
"""CI bounds must be on HR scale (positive), not log-HR (coefficient scale)."""
|
|
198
|
+
df = pd.DataFrame({
|
|
199
|
+
"time": [100.0, 200.0, 150.0, 300.0, 180.0, 220.0, 90.0, 350.0],
|
|
200
|
+
"event": [1, 0, 1, 0, 1, 0, 1, 0],
|
|
201
|
+
"arm": ["A", "A", "B", "B", "A", "A", "B", "B"],
|
|
202
|
+
"age": [45.0, 70.0, 55.0, 60.0, 50.0, 65.0, 58.0, 62.0],
|
|
203
|
+
"marker": [1.2, 3.4, 2.1, 4.5, 1.8, 2.9, 3.2, 4.1],
|
|
204
|
+
})
|
|
205
|
+
result = run_test(
|
|
206
|
+
"cox_ph_model", df,
|
|
207
|
+
outcome_col="time", group_col="arm",
|
|
208
|
+
time_col="time", event_col="event",
|
|
209
|
+
covariates=["age", "marker"],
|
|
210
|
+
)
|
|
211
|
+
assert result["statistic"] is not None
|
|
212
|
+
|
|
213
|
+
# Primary treatment: CI must be on HR scale (both > 0)
|
|
214
|
+
assert result["ci_lower"] > 0, f"ci_lower={result['ci_lower']} must be >0 (HR scale)"
|
|
215
|
+
assert result["ci_lower"] < result["statistic"] < result["ci_upper"], \
|
|
216
|
+
f"CI ({result['ci_lower']}, {result['ci_upper']}) must contain HR ({result['statistic']})"
|
|
217
|
+
|
|
218
|
+
# Per-covariate: every CI must be on HR scale
|
|
219
|
+
for cov in result.get("params", {}).get("per_covariate_results", []):
|
|
220
|
+
assert cov["ci_lower"] > 0, \
|
|
221
|
+
f"{cov['covariate']}: ci_lower={cov['ci_lower']} must be >0 (HR scale)"
|
|
222
|
+
assert cov["ci_lower"] < cov["hr"] < cov["ci_upper"], \
|
|
223
|
+
f"{cov['covariate']}: CI ({cov['ci_lower']}, {cov['ci_upper']}) must contain HR ({cov['hr']})"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
|
|
227
|
+
@pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
|
|
228
|
+
def test_cox_ph_model_overflow_ci_upper_becomes_inf():
|
|
229
|
+
"""Near-complete separation produces inf upper CI — must not crash."""
|
|
230
|
+
df = pd.DataFrame({
|
|
231
|
+
"time": [100.0, 200.0, 150.0, 300.0],
|
|
232
|
+
"event": [1, 0, 1, 0],
|
|
233
|
+
"arm": ["A", "A", "B", "B"],
|
|
234
|
+
"age": [45.0, 70.0, 55.0, 60.0],
|
|
235
|
+
})
|
|
236
|
+
result = run_test(
|
|
237
|
+
"cox_ph_model", df,
|
|
238
|
+
outcome_col="time", group_col="arm",
|
|
239
|
+
time_col="time", event_col="event",
|
|
240
|
+
covariates=["age"],
|
|
241
|
+
)
|
|
242
|
+
ci_upper = result["ci_upper"]
|
|
243
|
+
assert np.isinf(ci_upper), f"Expected inf upper CI for near-complete separation, got {ci_upper}"
|
|
244
|
+
assert result["ci_lower"] is not None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
|
|
248
|
+
@pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
|
|
249
|
+
def test_cox_ph_model_persists_params_to_db():
|
|
250
|
+
"""Cox PH model-level fields and covariate rows must be persisted to DB."""
|
|
251
|
+
import json, shutil
|
|
252
|
+
from core.database import get_connection, init_db, migrate_db, DATA_ROOT
|
|
253
|
+
|
|
254
|
+
study_id = "test_cox_ph_persist"
|
|
255
|
+
study_dir = DATA_ROOT / study_id
|
|
256
|
+
if study_dir.exists():
|
|
257
|
+
shutil.rmtree(study_dir)
|
|
258
|
+
|
|
259
|
+
conn = get_connection(study_id)
|
|
260
|
+
init_db(conn)
|
|
261
|
+
conn.execute(
|
|
262
|
+
"INSERT INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
|
|
263
|
+
(study_id, "test", "2025-01-01T00:00:00", str(study_dir)),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
df = pd.DataFrame({
|
|
267
|
+
"time": [100.0, 200.0, 150.0, 300.0, 180.0, 220.0],
|
|
268
|
+
"event": [1, 0, 1, 0, 1, 0],
|
|
269
|
+
"arm": ["A", "A", "B", "B", "A", "A"],
|
|
270
|
+
"age": [45.0, 70.0, 55.0, 60.0, 50.0, 65.0],
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
result = run_test(
|
|
274
|
+
"cox_ph_model", df,
|
|
275
|
+
outcome_col="time", group_col="arm",
|
|
276
|
+
time_col="time", event_col="event",
|
|
277
|
+
covariates=["age"],
|
|
278
|
+
var_types={"age": "continuous", "arm": "categorical"},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
now = "2025-01-01T00:00:00"
|
|
282
|
+
params = result.get("params", {})
|
|
283
|
+
cursor = conn.execute(
|
|
284
|
+
"""INSERT INTO analysis_results
|
|
285
|
+
(study_id, test_name, statistic, p_value, ci_lower, ci_upper,
|
|
286
|
+
effect_size_json, sample_counts_json, status_json,
|
|
287
|
+
is_pre_registered, provenance_json, computed_at,
|
|
288
|
+
lr_test_p, concordance_index, ph_diagnostics_json)
|
|
289
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)""",
|
|
290
|
+
(study_id, "cox_ph_model", result["statistic"], result["p_value"],
|
|
291
|
+
result.get("ci_lower"), result.get("ci_upper"),
|
|
292
|
+
json.dumps(result["effect_size"]) if result.get("effect_size") else None,
|
|
293
|
+
json.dumps(result["sample_counts"]) if result.get("sample_counts") else None,
|
|
294
|
+
json.dumps({"status": "completed"}),
|
|
295
|
+
json.dumps({"plan_version": 1}), now,
|
|
296
|
+
params.get("lr_test_p_value"), params.get("concordance_index"),
|
|
297
|
+
json.dumps(params.get("assumption_diagnostics")) if params.get("assumption_diagnostics") else None),
|
|
298
|
+
)
|
|
299
|
+
result_id = cursor.lastrowid
|
|
300
|
+
|
|
301
|
+
cov_results = params.get("per_covariate_results", [])
|
|
302
|
+
for cr in cov_results:
|
|
303
|
+
conn.execute(
|
|
304
|
+
"""INSERT INTO analysis_covariate_results
|
|
305
|
+
(result_id, covariate, hr, ci_lower, ci_upper,
|
|
306
|
+
wald_p, coef, se, z)
|
|
307
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
308
|
+
(result_id,
|
|
309
|
+
cr.get("covariate"), cr.get("hr"),
|
|
310
|
+
cr.get("ci_lower"), cr.get("ci_upper"),
|
|
311
|
+
cr.get("wald_p"), cr.get("coef"),
|
|
312
|
+
cr.get("se"), cr.get("z")),
|
|
313
|
+
)
|
|
314
|
+
conn.commit()
|
|
315
|
+
|
|
316
|
+
# Verify model-level fields persisted
|
|
317
|
+
cur = conn.execute("SELECT * FROM analysis_results WHERE id=?", (result_id,))
|
|
318
|
+
row = cur.fetchone()
|
|
319
|
+
assert row is not None
|
|
320
|
+
assert row["test_name"] == "cox_ph_model"
|
|
321
|
+
if params.get("lr_test_p_value") is not None:
|
|
322
|
+
assert abs(row["lr_test_p"] - params["lr_test_p_value"]) < 1e-6
|
|
323
|
+
if params.get("concordance_index") is not None:
|
|
324
|
+
assert abs(row["concordance_index"] - params["concordance_index"]) < 1e-6
|
|
325
|
+
|
|
326
|
+
# Verify covariate rows queryable via SQL
|
|
327
|
+
cur = conn.execute(
|
|
328
|
+
"SELECT * FROM analysis_covariate_results WHERE result_id=? ORDER BY id", (result_id,)
|
|
329
|
+
)
|
|
330
|
+
db_rows = cur.fetchall()
|
|
331
|
+
assert len(db_rows) == len(cov_results)
|
|
332
|
+
for db_row, expected in zip(db_rows, cov_results):
|
|
333
|
+
assert db_row["covariate"] == expected["covariate"]
|
|
334
|
+
if expected.get("hr") is not None:
|
|
335
|
+
assert abs(db_row["hr"] - expected["hr"]) < 1e-6
|
|
336
|
+
|
|
337
|
+
conn.close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_non_cox_result_inserts_with_null_new_columns():
|
|
341
|
+
"""Non-Cox-PH result types must insert cleanly with new columns as NULL."""
|
|
342
|
+
import json, shutil
|
|
343
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
344
|
+
|
|
345
|
+
study_id = "test_non_cox_null"
|
|
346
|
+
study_dir = DATA_ROOT / study_id
|
|
347
|
+
if study_dir.exists():
|
|
348
|
+
shutil.rmtree(study_dir)
|
|
349
|
+
|
|
350
|
+
conn = get_connection(study_id)
|
|
351
|
+
init_db(conn)
|
|
352
|
+
conn.execute(
|
|
353
|
+
"INSERT INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
|
|
354
|
+
(study_id, "test", "2025-01-01T00:00:00", str(study_dir)),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
now = "2025-01-01T00:00:00"
|
|
358
|
+
# Insert a chi-square result
|
|
359
|
+
conn.execute(
|
|
360
|
+
"""INSERT INTO analysis_results
|
|
361
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
362
|
+
is_pre_registered, provenance_json, computed_at)
|
|
363
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?)""",
|
|
364
|
+
(study_id, "chi_square", 3.84, 0.05,
|
|
365
|
+
json.dumps({"status": "completed"}),
|
|
366
|
+
json.dumps({"plan_version": 1}), now),
|
|
367
|
+
)
|
|
368
|
+
conn.commit()
|
|
369
|
+
|
|
370
|
+
cur = conn.execute(
|
|
371
|
+
"SELECT * FROM analysis_results WHERE study_id=? AND test_name=?", (study_id, "chi_square")
|
|
372
|
+
)
|
|
373
|
+
row = cur.fetchone()
|
|
374
|
+
assert row is not None
|
|
375
|
+
# New columns must be NULL for non-Cox results
|
|
376
|
+
assert row["lr_test_p"] is None
|
|
377
|
+
assert row["concordance_index"] is None
|
|
378
|
+
assert row["ph_diagnostics_json"] is None
|
|
379
|
+
|
|
380
|
+
conn.close()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Tests for STROBE checklist compliance checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
10
|
+
from core.masking.gate import seal_outcomes
|
|
11
|
+
from core.planning.study_plan import StudyPlan
|
|
12
|
+
from core.planning.lock import lock_plan
|
|
13
|
+
from core.reporting.strobe_checklist import check_study, generate_report, STROBE_ITEMS
|
|
14
|
+
|
|
15
|
+
STUDY_ID = "test_strobe"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _setup():
|
|
20
|
+
conn = get_connection(STUDY_ID)
|
|
21
|
+
init_db(conn)
|
|
22
|
+
conn.execute(
|
|
23
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
|
|
24
|
+
(STUDY_ID, "STROBE Test", "2025-01-01T00:00:00",
|
|
25
|
+
str(Path("data/studies") / STUDY_ID), "cohort"),
|
|
26
|
+
)
|
|
27
|
+
raw = f"raw_{STUDY_ID}"
|
|
28
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, response TEXT, treatment_arm TEXT)")
|
|
29
|
+
conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('65', 'CR', 'A')")
|
|
30
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
31
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
|
|
32
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'response', 'outcome', 'categorical')", (STUDY_ID,))
|
|
33
|
+
conn.commit()
|
|
34
|
+
conn.close()
|
|
35
|
+
yield
|
|
36
|
+
p = DATA_ROOT / STUDY_ID
|
|
37
|
+
if p.exists():
|
|
38
|
+
shutil.rmtree(p)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_check_study_returns_list():
|
|
42
|
+
items = check_study(STUDY_ID)
|
|
43
|
+
assert len(items) > 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_check_study_filters_by_type():
|
|
47
|
+
"""Only items applicable to 'cohort' should be in the response."""
|
|
48
|
+
items = check_study(STUDY_ID)
|
|
49
|
+
for item in items:
|
|
50
|
+
assert "cohort" in item.applies_to
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_item_3_satisfied_with_locked_plan():
|
|
54
|
+
"""Item 3 (objectives/hypotheses) should be satisfied after locking a plan."""
|
|
55
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort",
|
|
56
|
+
primary_comparison="survival by treatment arm")
|
|
57
|
+
lock_plan(STUDY_ID, plan)
|
|
58
|
+
|
|
59
|
+
items = check_study(STUDY_ID)
|
|
60
|
+
item3 = next(i for i in items if i.item_id == "3")
|
|
61
|
+
assert item3.satisfied is True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_generate_report_includes_status_counts():
|
|
65
|
+
report = generate_report(STUDY_ID)
|
|
66
|
+
assert "STROBE Compliance Report" in report
|
|
67
|
+
# Status symbols: ✓ satisfied, [ ] pending, ✗ unsatisfied
|
|
68
|
+
assert "✓" in report or " [" in report or "✗" in report
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_strobe_items_have_required_fields():
|
|
72
|
+
for item in STROBE_ITEMS:
|
|
73
|
+
assert item.item_id
|
|
74
|
+
assert item.section
|
|
75
|
+
assert item.description
|
|
76
|
+
assert len(item.applies_to) > 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_strobe_items_cover_22_base():
|
|
80
|
+
"""Should have at least 22 unique items (some are design-specific)."""
|
|
81
|
+
ids = set(i.item_id for i in STROBE_ITEMS)
|
|
82
|
+
assert len(ids) >= 22
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _add_completed_result(study_id, test_name="chi_square", extra=True):
|
|
86
|
+
"""Insert a completed analysis result for the test study."""
|
|
87
|
+
conn = get_connection(study_id)
|
|
88
|
+
init_db(conn)
|
|
89
|
+
from datetime import datetime, timezone
|
|
90
|
+
import json
|
|
91
|
+
conn.execute(
|
|
92
|
+
"""INSERT INTO analysis_results
|
|
93
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
94
|
+
sample_counts_json, is_pre_registered, computed_at)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
96
|
+
(study_id, test_name, 5.5, 0.24,
|
|
97
|
+
json.dumps({"status": "completed", "reason": None}),
|
|
98
|
+
json.dumps({"n_total": 100, "n_analyzed": 95, "n_excluded": 5}),
|
|
99
|
+
datetime.now(timezone.utc).isoformat()),
|
|
100
|
+
)
|
|
101
|
+
conn.commit()
|
|
102
|
+
conn.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_items_18_22_satisfied_with_analyses_no_draft():
|
|
106
|
+
"""Items 18-22 should be satisfied when analyses exist, even without writing draft."""
|
|
107
|
+
plan = StudyPlan(
|
|
108
|
+
study_id=STUDY_ID, study_type="cohort",
|
|
109
|
+
primary_comparison="survival by treatment arm",
|
|
110
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
|
|
111
|
+
)
|
|
112
|
+
lock_plan(STUDY_ID, plan)
|
|
113
|
+
_add_completed_result(STUDY_ID)
|
|
114
|
+
|
|
115
|
+
items = check_study(STUDY_ID)
|
|
116
|
+
|
|
117
|
+
for item in items:
|
|
118
|
+
if item.item_id in ("18", "19", "20", "21", "22"):
|
|
119
|
+
assert item.satisfied, (
|
|
120
|
+
f"Item {item.item_id} should be satisfied: {item.evidence}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_strobe_order_independence():
|
|
125
|
+
"""Strobe-check must produce consistent items 18-22 regardless of draft-file existence.
|
|
126
|
+
|
|
127
|
+
The live draft is generated in-memory from study data, so calling
|
|
128
|
+
check_study() before and after a write_draft() must report the same
|
|
129
|
+
satisfied/status for the draft-dependent items.
|
|
130
|
+
"""
|
|
131
|
+
plan = StudyPlan(
|
|
132
|
+
study_id=STUDY_ID, study_type="cohort",
|
|
133
|
+
primary_comparison="survival by treatment arm",
|
|
134
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
|
|
135
|
+
)
|
|
136
|
+
lock_plan(STUDY_ID, plan)
|
|
137
|
+
_add_completed_result(STUDY_ID)
|
|
138
|
+
|
|
139
|
+
# Strobe check when no draft file exists on disk → uses live in-memory draft
|
|
140
|
+
items_no_draft = check_study(STUDY_ID)
|
|
141
|
+
|
|
142
|
+
# Write draft to disk
|
|
143
|
+
from core.reporting.manuscript_draft import write_draft
|
|
144
|
+
write_draft(STUDY_ID)
|
|
145
|
+
assert (DATA_ROOT / STUDY_ID / "manuscript_draft.md").exists()
|
|
146
|
+
|
|
147
|
+
# Second strobe check (draft file now exists)
|
|
148
|
+
items_with_draft = check_study(STUDY_ID)
|
|
149
|
+
|
|
150
|
+
# Items 18-22 must be identical — the live draft always wins
|
|
151
|
+
for item_no, item_with in zip(items_no_draft, items_with_draft):
|
|
152
|
+
if item_no.item_id in ("18", "19", "20", "21", "22"):
|
|
153
|
+
assert item_no.satisfied == item_with.satisfied, (
|
|
154
|
+
f"Item {item_no.item_id}: satisfied differs "
|
|
155
|
+
f"(no_draft={item_no.satisfied}, with_draft={item_with.satisfied})"
|
|
156
|
+
)
|
|
157
|
+
assert item_no.status == item_with.status, (
|
|
158
|
+
f"Item {item_no.item_id}: status differs "
|
|
159
|
+
f"(no_draft={item_no.status}, with_draft={item_with.status})"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_item_22_non_bracketed_funding():
|
|
164
|
+
"""Item 22 should be satisfied because funding text is not in brackets."""
|
|
165
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort",
|
|
166
|
+
primary_comparison="test")
|
|
167
|
+
lock_plan(STUDY_ID, plan)
|
|
168
|
+
_add_completed_result(STUDY_ID)
|
|
169
|
+
|
|
170
|
+
items = check_study(STUDY_ID)
|
|
171
|
+
item22 = next(i for i in items if i.item_id == "22")
|
|
172
|
+
assert item22.satisfied, f"Item 22 should be satisfied: {item22.evidence}"
|