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.
Files changed (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -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
+
@@ -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.")