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
tests/test_csv_loader.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Tests for CSV ingestion — whitespace stripping and duplicate detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
12
|
+
from core.ingestion.csv_loader import load_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STUDY_ID = "test_csv_loader"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _setup():
|
|
20
|
+
p = DATA_ROOT / STUDY_ID
|
|
21
|
+
if p.exists():
|
|
22
|
+
shutil.rmtree(p)
|
|
23
|
+
conn = get_connection(STUDY_ID)
|
|
24
|
+
init_db(conn)
|
|
25
|
+
conn.execute(
|
|
26
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
|
|
27
|
+
(STUDY_ID, "CSV Loader Test", "2025-01-01", str(DATA_ROOT / STUDY_ID)),
|
|
28
|
+
)
|
|
29
|
+
conn.commit()
|
|
30
|
+
conn.close()
|
|
31
|
+
yield
|
|
32
|
+
if p.exists():
|
|
33
|
+
shutil.rmtree(p)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _csv_content(rows: list[list[str]]) -> str:
|
|
37
|
+
buf = io.StringIO()
|
|
38
|
+
w = csv.writer(buf)
|
|
39
|
+
w.writerows(rows)
|
|
40
|
+
return buf.getvalue()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_whitespace_stripped_from_all_string_columns():
|
|
44
|
+
"""Leading/trailing whitespace in string values should be stripped during ingest."""
|
|
45
|
+
content = _csv_content([
|
|
46
|
+
["patient_id", "sex", "treatment_arm"],
|
|
47
|
+
["001", " M", "A"], # leading space
|
|
48
|
+
["002", "F ", "B"], # trailing space
|
|
49
|
+
["003", " M ", " B "], # both sides
|
|
50
|
+
])
|
|
51
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
52
|
+
tmp.write_text(content)
|
|
53
|
+
load_file(STUDY_ID, str(tmp))
|
|
54
|
+
tmp.unlink()
|
|
55
|
+
|
|
56
|
+
conn = get_connection(STUDY_ID)
|
|
57
|
+
cur = conn.execute("SELECT sex FROM raw_test_csv_loader ORDER BY row_id")
|
|
58
|
+
sexes = [r["sex"] for r in cur.fetchall()]
|
|
59
|
+
conn.close()
|
|
60
|
+
|
|
61
|
+
# All whitespace should be stripped
|
|
62
|
+
assert sexes == ["M", "F", "M"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_duplicate_patient_id_warns(capsys):
|
|
66
|
+
"""Duplicate patient_id values should produce a warning on stderr."""
|
|
67
|
+
content = _csv_content([
|
|
68
|
+
["patient_id", "age"],
|
|
69
|
+
["P001", "45"],
|
|
70
|
+
["P002", "50"],
|
|
71
|
+
["P001", "55"], # duplicate
|
|
72
|
+
])
|
|
73
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
74
|
+
tmp.write_text(content)
|
|
75
|
+
load_file(STUDY_ID, str(tmp))
|
|
76
|
+
tmp.unlink()
|
|
77
|
+
|
|
78
|
+
stderr = capsys.readouterr().err
|
|
79
|
+
assert "Warning: duplicate patient identifiers" in stderr
|
|
80
|
+
assert "'P001'" in stderr
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_duplicate_patient_id_no_warning_when_unique(capsys):
|
|
84
|
+
"""No duplicate warning when all patient_ids are unique."""
|
|
85
|
+
content = _csv_content([
|
|
86
|
+
["patient_id", "age"],
|
|
87
|
+
["P001", "45"],
|
|
88
|
+
["P002", "50"],
|
|
89
|
+
["P003", "55"],
|
|
90
|
+
])
|
|
91
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
92
|
+
tmp.write_text(content)
|
|
93
|
+
load_file(STUDY_ID, str(tmp))
|
|
94
|
+
tmp.unlink()
|
|
95
|
+
|
|
96
|
+
stderr = capsys.readouterr().err
|
|
97
|
+
assert "Warning: duplicate patient identifiers" not in stderr
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_no_patient_id_column_no_crash():
|
|
101
|
+
"""Files without a patient_id column should ingest normally."""
|
|
102
|
+
content = _csv_content([
|
|
103
|
+
["age", "sex"],
|
|
104
|
+
["45", "M"],
|
|
105
|
+
["50", "F"],
|
|
106
|
+
])
|
|
107
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
108
|
+
tmp.write_text(content)
|
|
109
|
+
cols = load_file(STUDY_ID, str(tmp))
|
|
110
|
+
tmp.unlink()
|
|
111
|
+
assert cols == ["age", "sex"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_whitespace_only_values_become_null():
|
|
115
|
+
"""Values that are only whitespace should become NULL, not empty strings."""
|
|
116
|
+
content = _csv_content([
|
|
117
|
+
["patient_id", "age"],
|
|
118
|
+
["001", " "],
|
|
119
|
+
["002", ""],
|
|
120
|
+
])
|
|
121
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
122
|
+
tmp.write_text(content)
|
|
123
|
+
load_file(STUDY_ID, str(tmp))
|
|
124
|
+
tmp.unlink()
|
|
125
|
+
|
|
126
|
+
conn = get_connection(STUDY_ID)
|
|
127
|
+
ages = [r["age"] for r in conn.execute("SELECT age FROM raw_test_csv_loader ORDER BY row_id").fetchall()]
|
|
128
|
+
conn.close()
|
|
129
|
+
assert ages == [None, None]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_find_duplicate_patient_ids_detects_dupes():
|
|
133
|
+
"""find_duplicate_patient_ids() should detect ingested duplicates."""
|
|
134
|
+
content = _csv_content([
|
|
135
|
+
["patient_id", "age"],
|
|
136
|
+
["P001", "45"],
|
|
137
|
+
["P002", "50"],
|
|
138
|
+
["P001", "55"],
|
|
139
|
+
])
|
|
140
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
141
|
+
tmp.write_text(content)
|
|
142
|
+
load_file(STUDY_ID, str(tmp))
|
|
143
|
+
tmp.unlink()
|
|
144
|
+
|
|
145
|
+
from core.ingestion.csv_loader import find_duplicate_patient_ids
|
|
146
|
+
dupes = find_duplicate_patient_ids(STUDY_ID)
|
|
147
|
+
assert len(dupes) == 1
|
|
148
|
+
assert dupes[0][0] == "P001"
|
|
149
|
+
assert dupes[0][1] == 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_find_duplicate_patient_ids_no_dupes():
|
|
153
|
+
"""find_duplicate_patient_ids() returns empty when no duplicates."""
|
|
154
|
+
content = _csv_content([
|
|
155
|
+
["patient_id", "age"],
|
|
156
|
+
["P001", "45"],
|
|
157
|
+
["P002", "50"],
|
|
158
|
+
])
|
|
159
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
160
|
+
tmp.write_text(content)
|
|
161
|
+
load_file(STUDY_ID, str(tmp))
|
|
162
|
+
tmp.unlink()
|
|
163
|
+
|
|
164
|
+
from core.ingestion.csv_loader import find_duplicate_patient_ids
|
|
165
|
+
assert find_duplicate_patient_ids(STUDY_ID) == []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_na_values_converts_unknown_to_missing():
|
|
169
|
+
"""--na-values 'unknown' should make 'unknown' register as missing."""
|
|
170
|
+
content = _csv_content([
|
|
171
|
+
["patient_id", "treatment_arm"],
|
|
172
|
+
["P001", "A"],
|
|
173
|
+
["P002", "unknown"],
|
|
174
|
+
["P003", "B"],
|
|
175
|
+
])
|
|
176
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
177
|
+
tmp.write_text(content)
|
|
178
|
+
load_file(STUDY_ID, str(tmp), na_values=["unknown"])
|
|
179
|
+
tmp.unlink()
|
|
180
|
+
|
|
181
|
+
conn = get_connection(STUDY_ID)
|
|
182
|
+
arms = [r["treatment_arm"] for r in conn.execute("SELECT treatment_arm FROM raw_test_csv_loader ORDER BY row_id").fetchall()]
|
|
183
|
+
conn.close()
|
|
184
|
+
# 'unknown' should be None (missing), not the string 'unknown'
|
|
185
|
+
assert arms == ["A", None, "B"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_na_values_multiple_sentinels():
|
|
189
|
+
"""--na-values 'unknown,missing' should convert both to missing."""
|
|
190
|
+
content = _csv_content([
|
|
191
|
+
["patient_id", "treatment_arm"],
|
|
192
|
+
["P001", "A"],
|
|
193
|
+
["P002", "unknown"],
|
|
194
|
+
["P003", "missing"],
|
|
195
|
+
])
|
|
196
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
197
|
+
tmp.write_text(content)
|
|
198
|
+
load_file(STUDY_ID, str(tmp), na_values=["unknown", "missing"])
|
|
199
|
+
tmp.unlink()
|
|
200
|
+
|
|
201
|
+
conn = get_connection(STUDY_ID)
|
|
202
|
+
arms = [r["treatment_arm"] for r in conn.execute("SELECT treatment_arm FROM raw_test_csv_loader ORDER BY row_id").fetchall()]
|
|
203
|
+
conn.close()
|
|
204
|
+
assert arms == ["A", None, None]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_na_values_default_behavior_unchanged():
|
|
208
|
+
"""Without --na-values, 'unknown' remains a literal category."""
|
|
209
|
+
content = _csv_content([
|
|
210
|
+
["patient_id", "treatment_arm"],
|
|
211
|
+
["P001", "A"],
|
|
212
|
+
["P002", "unknown"],
|
|
213
|
+
["P003", "B"],
|
|
214
|
+
])
|
|
215
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
216
|
+
tmp.write_text(content)
|
|
217
|
+
load_file(STUDY_ID, str(tmp))
|
|
218
|
+
tmp.unlink()
|
|
219
|
+
|
|
220
|
+
conn = get_connection(STUDY_ID)
|
|
221
|
+
arms = [r["treatment_arm"] for r in conn.execute("SELECT treatment_arm FROM raw_test_csv_loader ORDER BY row_id").fetchall()]
|
|
222
|
+
conn.close()
|
|
223
|
+
# 'unknown' should remain as the string 'unknown'
|
|
224
|
+
assert arms == ["A", "unknown", "B"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- re-ingest guard ---
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_reingest_rejected_with_clear_error():
|
|
231
|
+
"""Second ingest on same study_id fails with clear message, not IntegrityError."""
|
|
232
|
+
content = _csv_content([
|
|
233
|
+
["patient_id", "age"],
|
|
234
|
+
["P001", "45"],
|
|
235
|
+
["P002", "50"],
|
|
236
|
+
])
|
|
237
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
238
|
+
tmp.write_text(content)
|
|
239
|
+
load_file(STUDY_ID, str(tmp)) # first ingest succeeds
|
|
240
|
+
tmp.unlink()
|
|
241
|
+
|
|
242
|
+
# second ingest must fail
|
|
243
|
+
tmp2 = DATA_ROOT / STUDY_ID / "_test2.csv"
|
|
244
|
+
tmp2.write_text(content)
|
|
245
|
+
with pytest.raises(SystemExit) as exc:
|
|
246
|
+
load_file(STUDY_ID, str(tmp2))
|
|
247
|
+
assert exc.value.code != 0 # non-zero exit
|
|
248
|
+
# original data untouched
|
|
249
|
+
conn = get_connection(STUDY_ID)
|
|
250
|
+
ages = [r["age"] for r in conn.execute("SELECT age FROM raw_test_csv_loader ORDER BY row_id").fetchall()]
|
|
251
|
+
conn.close()
|
|
252
|
+
assert ages == ["45", "50"]
|
|
253
|
+
tmp2.unlink()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_reingest_with_force_clears_and_reingests():
|
|
257
|
+
"""--force-reingest clears downstream state and re-ingests."""
|
|
258
|
+
content = _csv_content([
|
|
259
|
+
["patient_id", "age"],
|
|
260
|
+
["P001", "45"],
|
|
261
|
+
["P002", "50"],
|
|
262
|
+
])
|
|
263
|
+
tmp = DATA_ROOT / STUDY_ID / "_test.csv"
|
|
264
|
+
tmp.write_text(content)
|
|
265
|
+
load_file(STUDY_ID, str(tmp)) # first ingest
|
|
266
|
+
tmp.unlink()
|
|
267
|
+
|
|
268
|
+
# seal outcomes so shadow table exists
|
|
269
|
+
from core.masking.gate import seal_outcomes
|
|
270
|
+
seal_outcomes(STUDY_ID)
|
|
271
|
+
|
|
272
|
+
# classify a variable so variables table has a row
|
|
273
|
+
conn = get_connection(STUDY_ID)
|
|
274
|
+
conn.execute(
|
|
275
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
|
|
276
|
+
"VALUES (1, ?, 'age', 'baseline', 'continuous')",
|
|
277
|
+
(STUDY_ID,),
|
|
278
|
+
)
|
|
279
|
+
conn.commit()
|
|
280
|
+
conn.close()
|
|
281
|
+
|
|
282
|
+
# force-reingest
|
|
283
|
+
content2 = _csv_content([
|
|
284
|
+
["patient_id", "age", "sex"], # new column
|
|
285
|
+
["P003", "30", "M"],
|
|
286
|
+
["P004", "40", "F"],
|
|
287
|
+
])
|
|
288
|
+
tmp2 = DATA_ROOT / STUDY_ID / "_test2.csv"
|
|
289
|
+
tmp2.write_text(content2)
|
|
290
|
+
cols = load_file(STUDY_ID, str(tmp2), force=True)
|
|
291
|
+
tmp2.unlink()
|
|
292
|
+
|
|
293
|
+
# downstream state cleared — new data in place
|
|
294
|
+
assert cols == ["patient_id", "age", "sex"]
|
|
295
|
+
conn = get_connection(STUDY_ID)
|
|
296
|
+
cur = conn.execute("SELECT age, sex FROM raw_test_csv_loader ORDER BY row_id")
|
|
297
|
+
rows = cur.fetchall()
|
|
298
|
+
conn.close()
|
|
299
|
+
assert len(rows) == 2
|
|
300
|
+
assert rows[0]["age"] == "30"
|
|
301
|
+
assert rows[0]["sex"] == "M"
|
|
302
|
+
|
|
303
|
+
# variables table was cleared
|
|
304
|
+
conn = get_connection(STUDY_ID)
|
|
305
|
+
cur = conn.execute("SELECT COUNT(*) AS cnt FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
306
|
+
assert cur.fetchone()["cnt"] == 0
|
|
307
|
+
conn.close()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_custom_na_values_flag_parses_as_missing():
|
|
311
|
+
"""Custom sentinel (e.g. '-999') in --na-values is parsed as missing/NULL."""
|
|
312
|
+
content = _csv_content([
|
|
313
|
+
["patient_id", "age", "income"],
|
|
314
|
+
["P001", "45", "-999"],
|
|
315
|
+
["P002", "50", "unknown"],
|
|
316
|
+
])
|
|
317
|
+
tmp = DATA_ROOT / STUDY_ID / "_test_na.csv"
|
|
318
|
+
tmp.write_text(content)
|
|
319
|
+
load_file(STUDY_ID, str(tmp), na_values=["-999", "unknown"], force=True)
|
|
320
|
+
tmp.unlink()
|
|
321
|
+
|
|
322
|
+
conn = get_connection(STUDY_ID)
|
|
323
|
+
cur = conn.execute("SELECT income FROM raw_test_csv_loader ORDER BY row_id")
|
|
324
|
+
incomes = [r["income"] for r in cur.fetchall()]
|
|
325
|
+
conn.close()
|
|
326
|
+
|
|
327
|
+
assert incomes == [None, None]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_custom_na_values_without_flag_treats_as_literal():
|
|
331
|
+
"""Without na_values flag, '-999' is treated as literal value, not missing."""
|
|
332
|
+
content = _csv_content([
|
|
333
|
+
["patient_id", "age", "income"],
|
|
334
|
+
["P001", "45", "-999"],
|
|
335
|
+
])
|
|
336
|
+
tmp = DATA_ROOT / STUDY_ID / "_test_na_literal.csv"
|
|
337
|
+
tmp.write_text(content)
|
|
338
|
+
load_file(STUDY_ID, str(tmp), force=True)
|
|
339
|
+
tmp.unlink()
|
|
340
|
+
|
|
341
|
+
conn = get_connection(STUDY_ID)
|
|
342
|
+
cur = conn.execute("SELECT income FROM raw_test_csv_loader ORDER BY row_id")
|
|
343
|
+
incomes = [r["income"] for r in cur.fetchall()]
|
|
344
|
+
conn.close()
|
|
345
|
+
|
|
346
|
+
assert incomes == ["-999"]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_default_na_sentinels_work_with_and_without_flag():
|
|
350
|
+
"""Default sentinels ('', 'NA', 'N/A') are parsed as missing with or without custom na_values."""
|
|
351
|
+
content = _csv_content([
|
|
352
|
+
["patient_id", "age", "income"],
|
|
353
|
+
["P001", "", "NA"],
|
|
354
|
+
["P002", "N/A", "null"],
|
|
355
|
+
])
|
|
356
|
+
tmp = DATA_ROOT / STUDY_ID / "_test_na_defaults.csv"
|
|
357
|
+
tmp.write_text(content)
|
|
358
|
+
load_file(STUDY_ID, str(tmp), na_values=["custom_sentinel"], force=True)
|
|
359
|
+
tmp.unlink()
|
|
360
|
+
|
|
361
|
+
conn = get_connection(STUDY_ID)
|
|
362
|
+
cur = conn.execute("SELECT age, income FROM raw_test_csv_loader ORDER BY row_id")
|
|
363
|
+
rows = cur.fetchall()
|
|
364
|
+
conn.close()
|
|
365
|
+
|
|
366
|
+
assert [r["age"] for r in rows] == [None, None]
|
|
367
|
+
assert [r["income"] for r in rows] == [None, None]
|
|
368
|
+
|
tests/test_end_to_end.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""End-to-end pipeline validation using synthetic myeloma data (EMD study schema).
|
|
2
|
+
|
|
3
|
+
Simulates a retrospective cohort study of 21 patients with multiple myeloma
|
|
4
|
+
and extramedullary disease (EMD). Variables:
|
|
5
|
+
- Baseline: age, sex, iss_stage, prior_lines, high_risk_cytogenetics
|
|
6
|
+
- Exposure: treatment_arm (A / B)
|
|
7
|
+
- Outcome: response_category (CR/PR/MR/SD/PD), pfs_days + pfs_event, os_days + os_event
|
|
8
|
+
|
|
9
|
+
This is a self-check test, not a formal pytest test — run it directly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import csv
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Ensure the research-tool package is importable
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
25
|
+
|
|
26
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
27
|
+
from core.ingestion.csv_loader import load_file
|
|
28
|
+
from core.ingestion.variable_classifier import classify_variables_interactive, _classify_batch
|
|
29
|
+
from core.masking.gate import seal_outcomes, unmask_study, is_masked
|
|
30
|
+
from core.planning.study_plan import StudyPlan
|
|
31
|
+
from core.planning.lock import lock_plan, load_plan
|
|
32
|
+
from core.stats.descriptive import generate_table1
|
|
33
|
+
from core.stats.inferential import run_test
|
|
34
|
+
from core.stats.multiple_comparisons import correct
|
|
35
|
+
from core.reporting.strobe_checklist import generate_report
|
|
36
|
+
from core.reporting.manuscript_draft import write_draft
|
|
37
|
+
|
|
38
|
+
STUDY_NAME = "EMD Myeloma Validation"
|
|
39
|
+
STUDY_ID = "emd_val_001"
|
|
40
|
+
SEED = 2024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _synthetic_emd_csv() -> str:
|
|
44
|
+
"""Generate 21-row synthetic CSV matching a retrospective myeloma EMD study."""
|
|
45
|
+
import random
|
|
46
|
+
rng = random.Random(SEED)
|
|
47
|
+
rows = [
|
|
48
|
+
["patient_id", "age", "sex", "iss_stage", "prior_lines",
|
|
49
|
+
"high_risk_cytogenetics", "treatment_arm", "response_category",
|
|
50
|
+
"pfs_days", "pfs_event", "os_days", "os_event"]
|
|
51
|
+
]
|
|
52
|
+
for i in range(21):
|
|
53
|
+
pid = f"EMD_{i+1:03d}"
|
|
54
|
+
age = rng.randint(40, 85)
|
|
55
|
+
sex = rng.choice(["M", "F"])
|
|
56
|
+
iss = rng.choices(["I", "II", "III"], weights=[30, 40, 30])[0]
|
|
57
|
+
prior = rng.randint(0, 4)
|
|
58
|
+
hr_cyto = rng.choice(["yes", "no"])
|
|
59
|
+
arm = rng.choice(["A", "B"])
|
|
60
|
+
resp = rng.choices(
|
|
61
|
+
["CR", "PR", "MR", "SD", "PD"],
|
|
62
|
+
weights=[15, 30, 20, 20, 15],
|
|
63
|
+
)[0]
|
|
64
|
+
pfs = max(30, int(rng.expovariate(1 / 250)))
|
|
65
|
+
pfs_ev = 1 if resp in ("PD",) else rng.choices([0, 1], weights=[35, 65])[0]
|
|
66
|
+
os_d = pfs + max(0, int(rng.expovariate(1 / 300)))
|
|
67
|
+
os_ev = 1 if pfs_ev else rng.choices([0, 1], weights=[50, 50])[0]
|
|
68
|
+
rows.append([pid, age, sex, iss, prior, hr_cyto, arm, resp, pfs, pfs_ev, os_d, os_ev])
|
|
69
|
+
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
w = csv.writer(buf)
|
|
72
|
+
w.writerows(rows)
|
|
73
|
+
return buf.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _assert_near(actual, expected, tol=0.15, label=""):
|
|
77
|
+
"""Assert that actual is within tol of expected (relative)."""
|
|
78
|
+
if expected == 0:
|
|
79
|
+
assert abs(actual) < tol, f"{label}: expected ~0, got {actual}"
|
|
80
|
+
return
|
|
81
|
+
rel_err = abs(actual - expected) / abs(expected)
|
|
82
|
+
assert rel_err <= tol, (
|
|
83
|
+
f"{label}: expected {expected}, got {actual} "
|
|
84
|
+
f"(relative error {rel_err:.2%} > {tol:.0%})"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_pipeline() -> dict:
|
|
89
|
+
"""Run the full pipeline end-to-end.
|
|
90
|
+
|
|
91
|
+
Returns a dict with summary stats and status.
|
|
92
|
+
"""
|
|
93
|
+
summary = {"phase": "", "status": "ok", "issues": []}
|
|
94
|
+
|
|
95
|
+
# ── 1. Create study ────────────────────────────────────────────────────
|
|
96
|
+
summary["phase"] = "create_study"
|
|
97
|
+
conn = get_connection(STUDY_ID)
|
|
98
|
+
init_db(conn)
|
|
99
|
+
conn.execute(
|
|
100
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
101
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
102
|
+
(STUDY_ID, STUDY_NAME, "2025-06-01T00:00:00",
|
|
103
|
+
str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
104
|
+
)
|
|
105
|
+
conn.commit()
|
|
106
|
+
conn.close()
|
|
107
|
+
print("✓ Study created")
|
|
108
|
+
|
|
109
|
+
# ── 2. Ingest CSV ─────────────────────────────────────────────────────
|
|
110
|
+
summary["phase"] = "ingest"
|
|
111
|
+
csv_content = _synthetic_emd_csv()
|
|
112
|
+
tmp = Path(tempfile.mkstemp(suffix=".csv")[1])
|
|
113
|
+
tmp.write_text(csv_content)
|
|
114
|
+
columns = load_file(STUDY_ID, str(tmp))
|
|
115
|
+
tmp.unlink()
|
|
116
|
+
assert len(columns) == 12 # patient_id + 11 data columns
|
|
117
|
+
print(f"✓ Data ingested: {len(columns)} columns")
|
|
118
|
+
summary["n_columns"] = len(columns)
|
|
119
|
+
|
|
120
|
+
# ── 3. Classify variables + seal outcomes ─────────────────────────────
|
|
121
|
+
summary["phase"] = "classify"
|
|
122
|
+
suggestions = classify_variables_interactive(STUDY_ID, ["age", "sex", "iss_stage",
|
|
123
|
+
"prior_lines", "high_risk_cytogenetics", "treatment_arm",
|
|
124
|
+
"response_category", "pfs_days", "pfs_event", "os_days", "os_event"])
|
|
125
|
+
_classify_batch(STUDY_ID, suggestions)
|
|
126
|
+
# Seal outcomes into shadow table (physical storage-level masking)
|
|
127
|
+
seal_outcomes(STUDY_ID)
|
|
128
|
+
conn = get_connection(STUDY_ID)
|
|
129
|
+
cur = conn.execute("SELECT COUNT(*) as cnt FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
130
|
+
n_vars = cur.fetchone()["cnt"]
|
|
131
|
+
conn.close()
|
|
132
|
+
assert n_vars > 0
|
|
133
|
+
print(f"✓ Variables classified: {n_vars}")
|
|
134
|
+
|
|
135
|
+
# ── 4. Explore baseline (masked) ──────────────────────────────────────
|
|
136
|
+
summary["phase"] = "explore_baseline"
|
|
137
|
+
conn = get_connection(STUDY_ID)
|
|
138
|
+
cur = conn.execute("SELECT age FROM raw_emd_val_001")
|
|
139
|
+
rows = cur.fetchall()
|
|
140
|
+
ages = [r["age"] for r in rows]
|
|
141
|
+
assert len(ages) == 21
|
|
142
|
+
# Outcome columns should be physically NULL
|
|
143
|
+
cur2 = conn.execute("SELECT response_category FROM raw_emd_val_001")
|
|
144
|
+
for r in cur2.fetchall():
|
|
145
|
+
assert r["response_category"] is None, "outcome must be NULL post-seal"
|
|
146
|
+
conn.close()
|
|
147
|
+
print(f"✓ Baseline explored (outcomes physically NULL in DB): {len(ages)} rows, "
|
|
148
|
+
f"age range {min(ages)}-{max(ages)}")
|
|
149
|
+
|
|
150
|
+
# ── 5. Table 1 (masked, baseline only) ────────────────────────────────
|
|
151
|
+
summary["phase"] = "table1_masked"
|
|
152
|
+
tbl = generate_table1(STUDY_ID, groupby="treatment_arm")
|
|
153
|
+
assert len(tbl) > 0
|
|
154
|
+
print(f"✓ Table 1 generated ({len(tbl)} rows), baseline only")
|
|
155
|
+
|
|
156
|
+
# ── 6. Declare and lock study plan ────────────────────────────────────
|
|
157
|
+
summary["phase"] = "lock_plan"
|
|
158
|
+
plan = StudyPlan(
|
|
159
|
+
study_id=STUDY_ID,
|
|
160
|
+
study_type="cohort",
|
|
161
|
+
primary_comparison="PFS and response by treatment arm",
|
|
162
|
+
primary_outcome_variable_ids=[7, 8], # response_category, pfs_days
|
|
163
|
+
planned_tests=[
|
|
164
|
+
{"variable_id": 7, "variable_name": "response_category",
|
|
165
|
+
"test_name": "chi_square",
|
|
166
|
+
"rationale": "Compare response distribution between arms"},
|
|
167
|
+
{"variable_id": 8, "variable_name": "pfs_days",
|
|
168
|
+
"test_name": "kaplan_meier_logrank",
|
|
169
|
+
"rationale": "Compare PFS between treatment arms"},
|
|
170
|
+
],
|
|
171
|
+
covariates=[1, 3, 5], # age, iss_stage, high_risk_cytogenetics
|
|
172
|
+
)
|
|
173
|
+
path = lock_plan(STUDY_ID, plan)
|
|
174
|
+
assert path.exists()
|
|
175
|
+
assert "v1" in path.name
|
|
176
|
+
print(f"✓ Plan locked: {path.name}")
|
|
177
|
+
|
|
178
|
+
# ── 7. Unmask ─────────────────────────────────────────────────────────
|
|
179
|
+
summary["phase"] = "unmask"
|
|
180
|
+
unmask_study(STUDY_ID)
|
|
181
|
+
assert not is_masked(STUDY_ID)
|
|
182
|
+
# Verify outcomes visible
|
|
183
|
+
conn = get_connection(STUDY_ID)
|
|
184
|
+
cur3 = conn.execute("SELECT response_category FROM raw_emd_val_001")
|
|
185
|
+
outcome_vals = [r["response_category"] for r in cur3.fetchall()]
|
|
186
|
+
conn.close()
|
|
187
|
+
assert all(v is not None for v in outcome_vals)
|
|
188
|
+
print(f"✓ Study unmasked, outcomes visible ({len(outcome_vals)} values)")
|
|
189
|
+
|
|
190
|
+
# ── 8. Run pre-registered analyses ────────────────────────────────────
|
|
191
|
+
summary["phase"] = "analyze"
|
|
192
|
+
conn = get_connection(STUDY_ID)
|
|
193
|
+
import pandas as pd
|
|
194
|
+
df = pd.read_sql_query("SELECT * FROM raw_emd_val_001", conn)
|
|
195
|
+
conn.close()
|
|
196
|
+
|
|
197
|
+
results = []
|
|
198
|
+
p_vals = []
|
|
199
|
+
conn = get_connection(STUDY_ID)
|
|
200
|
+
init_db(conn)
|
|
201
|
+
|
|
202
|
+
for t in plan.planned_tests:
|
|
203
|
+
var_name = t.get("variable_name", "")
|
|
204
|
+
test_name = t.get("test_name", "")
|
|
205
|
+
kwargs = {"outcome_col": var_name, "group_col": "treatment_arm"}
|
|
206
|
+
if test_name == "kaplan_meier_logrank":
|
|
207
|
+
kwargs["time_col"] = "pfs_days"
|
|
208
|
+
kwargs["event_col"] = "pfs_event"
|
|
209
|
+
result = run_test(test_name, df, **kwargs)
|
|
210
|
+
p_vals.append(result["p_value"] or 1.0)
|
|
211
|
+
results.append(result)
|
|
212
|
+
p_str = f"p={result['p_value']:.4f}" if result['p_value'] else "error"
|
|
213
|
+
print(f" {test_name}: stat={result['statistic']:.4f}, {p_str}")
|
|
214
|
+
|
|
215
|
+
# Multiple comparisons correction
|
|
216
|
+
if len(results) > 1:
|
|
217
|
+
corrected = correct(p_vals)
|
|
218
|
+
for r, cp in zip(results, corrected):
|
|
219
|
+
r["adjusted_p_value"] = cp
|
|
220
|
+
elif results:
|
|
221
|
+
results[0]["adjusted_p_value"] = results[0]["p_value"]
|
|
222
|
+
|
|
223
|
+
# Store
|
|
224
|
+
from datetime import datetime, timezone
|
|
225
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
226
|
+
for r in results:
|
|
227
|
+
conn.execute(
|
|
228
|
+
"INSERT INTO analysis_results "
|
|
229
|
+
"(study_id, study_plan_version, variable_ids_used, test_name, "
|
|
230
|
+
"statistic, p_value, adjusted_p_value, ci_lower, ci_upper, "
|
|
231
|
+
"is_pre_registered, provenance_json, computed_at) "
|
|
232
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
|
233
|
+
(STUDY_ID, plan.version, json.dumps([]),
|
|
234
|
+
r["test_name"], r["statistic"], r["p_value"],
|
|
235
|
+
r.get("adjusted_p_value"), r.get("ci_lower"), r.get("ci_upper"),
|
|
236
|
+
json.dumps({"plan_version": plan.version}), now),
|
|
237
|
+
)
|
|
238
|
+
conn.commit()
|
|
239
|
+
conn.close()
|
|
240
|
+
|
|
241
|
+
summary["n_analyses"] = len(results)
|
|
242
|
+
print(f"✓ Analyses complete: {len(results)} pre-registered tests")
|
|
243
|
+
|
|
244
|
+
# ── 9. STROBE check ──────────────────────────────────────────────────
|
|
245
|
+
summary["phase"] = "strobe_check"
|
|
246
|
+
report = generate_report(STUDY_ID)
|
|
247
|
+
n_satisfied = report.count("✓")
|
|
248
|
+
summary["strobe_satisfied"] = n_satisfied
|
|
249
|
+
print(f"✓ STROBE check: {n_satisfied} items satisfied")
|
|
250
|
+
print(report)
|
|
251
|
+
|
|
252
|
+
# ── 10. Manuscript draft ─────────────────────────────────────────────
|
|
253
|
+
summary["phase"] = "draft"
|
|
254
|
+
draft_path = write_draft(STUDY_ID)
|
|
255
|
+
assert draft_path.exists()
|
|
256
|
+
draft_text = draft_path.read_text()
|
|
257
|
+
assert "EXPLORATORY_POST_HOC" not in draft_text # no post-hoc in this run
|
|
258
|
+
print(f"✓ Manuscript draft written to {draft_path}")
|
|
259
|
+
|
|
260
|
+
# ── Verifications ────────────────────────────────────────────────────
|
|
261
|
+
summary["phase"] = "verification"
|
|
262
|
+
|
|
263
|
+
# Verify the draft is valid markdown with key sections
|
|
264
|
+
for section in ["Abstract", "Introduction", "Methods", "Results", "Discussion",
|
|
265
|
+
"STROBE Checklist"]:
|
|
266
|
+
assert section.lower() in draft_text.lower(), f"Missing section: {section}"
|
|
267
|
+
print(f"✓ Draft contains all IMRaD sections")
|
|
268
|
+
|
|
269
|
+
# Verify provenance file exists
|
|
270
|
+
provenance_path = DATA_ROOT / STUDY_ID / "provenance.json"
|
|
271
|
+
assert provenance_path.exists() is False # we used direct SQL, not the tracker
|
|
272
|
+
|
|
273
|
+
summary["status"] = "ok"
|
|
274
|
+
summary["phase"] = "complete"
|
|
275
|
+
|
|
276
|
+
return summary
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cleanup():
|
|
280
|
+
"""Remove test study data."""
|
|
281
|
+
p = DATA_ROOT / STUDY_ID
|
|
282
|
+
if p.exists():
|
|
283
|
+
shutil.rmtree(p)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
print("=" * 60)
|
|
288
|
+
print(f"End-to-End Validation: {STUDY_NAME}")
|
|
289
|
+
print("=" * 60)
|
|
290
|
+
try:
|
|
291
|
+
result = run_pipeline()
|
|
292
|
+
print()
|
|
293
|
+
print("=" * 60)
|
|
294
|
+
print(f"VALIDATION COMPLETE: {result['status']}")
|
|
295
|
+
print(f" Phases completed: {result['phase']}")
|
|
296
|
+
print(f" Columns ingested: {result.get('n_columns', '?')}")
|
|
297
|
+
print(f" Analyses run: {result.get('n_analyses', '?')}")
|
|
298
|
+
print(f" STROBE items satisfied: {result.get('strobe_satisfied', '?')}")
|
|
299
|
+
print("=" * 60)
|
|
300
|
+
finally:
|
|
301
|
+
cleanup()
|
|
302
|
+
print("Cleanup complete.")
|