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,350 @@
|
|
|
1
|
+
"""Tests for statistical test selector."""
|
|
2
|
+
|
|
3
|
+
from core.planning.test_selector import suggested_tests, check_assumptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_categorical_two_group():
|
|
7
|
+
tests = suggested_tests("categorical", "cohort", 2)
|
|
8
|
+
names = [t["test_name"] for t in tests]
|
|
9
|
+
assert "chi_square" in names
|
|
10
|
+
assert "fishers_exact" in names
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_continuous_two_group():
|
|
14
|
+
tests = suggested_tests("continuous", "cohort", 2)
|
|
15
|
+
names = [t["test_name"] for t in tests]
|
|
16
|
+
assert "t_test" in names
|
|
17
|
+
assert "mann_whitney_u" in names
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_continuous_two_group_paired():
|
|
21
|
+
tests = suggested_tests("continuous", "cohort", 2, is_paired=True)
|
|
22
|
+
names = [t["test_name"] for t in tests]
|
|
23
|
+
assert "paired_t_test" in names
|
|
24
|
+
assert "wilcoxon_signed_rank" in names
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_continuous_multigroup():
|
|
28
|
+
tests = suggested_tests("continuous", "cohort", 3)
|
|
29
|
+
names = [t["test_name"] for t in tests]
|
|
30
|
+
assert "anova" in names
|
|
31
|
+
assert "kruskal_wallis" in names
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_time_to_event():
|
|
35
|
+
tests = suggested_tests("time_to_event", "cohort", 2)
|
|
36
|
+
names = [t["test_name"] for t in tests]
|
|
37
|
+
assert "kaplan_meier_logrank" in names
|
|
38
|
+
assert "cox_proportional_hazards" in names
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_unknown_type_returns_empty():
|
|
42
|
+
tests = suggested_tests("unknown", "cohort", 2)
|
|
43
|
+
assert tests == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_categorical_paired():
|
|
47
|
+
tests = suggested_tests("categorical", "case_control", 2, is_paired=True)
|
|
48
|
+
names = [t["test_name"] for t in tests]
|
|
49
|
+
assert "mcnemar" in names
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_small_n_flips_order():
|
|
53
|
+
"""Below 30 patients, fisher_exact should be listed first."""
|
|
54
|
+
tests_small = suggested_tests("categorical", "cohort", 2, n_total=21)
|
|
55
|
+
assert tests_small[0]["test_name"] == "fishers_exact"
|
|
56
|
+
|
|
57
|
+
tests_large = suggested_tests("categorical", "cohort", 2, n_total=200)
|
|
58
|
+
assert tests_large[0]["test_name"] == "chi_square"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_chi_square_sparse_warning():
|
|
62
|
+
"""21 patients, 2 arms, 5-level outcome → expected counts < 5 → warn."""
|
|
63
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
64
|
+
import shutil
|
|
65
|
+
|
|
66
|
+
study_id = "test_sparse"
|
|
67
|
+
if (DATA_ROOT / study_id).exists():
|
|
68
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
69
|
+
|
|
70
|
+
conn = get_connection(study_id)
|
|
71
|
+
init_db(conn)
|
|
72
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
73
|
+
(study_id, "Sparse", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
74
|
+
raw = f"raw_{study_id}"
|
|
75
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, response TEXT, treatment_arm TEXT)")
|
|
76
|
+
|
|
77
|
+
# 21 patients: 2 arms (10/11 split), 5 response categories (~4 each)
|
|
78
|
+
import random
|
|
79
|
+
rng = random.Random(42)
|
|
80
|
+
arms = ["A"] * 10 + ["B"] * 11
|
|
81
|
+
rng.shuffle(arms)
|
|
82
|
+
outcomes = ["CR", "PR", "MR", "SD", "PD"]
|
|
83
|
+
for i, arm in enumerate(arms):
|
|
84
|
+
outcome = outcomes[i % 5]
|
|
85
|
+
conn.execute(f"INSERT INTO {raw} (response, treatment_arm) VALUES (?, ?)", (outcome, arm))
|
|
86
|
+
|
|
87
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
88
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
89
|
+
(study_id, "response", "outcome", "categorical"))
|
|
90
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
91
|
+
(study_id, "treatment_arm", "baseline", "categorical"))
|
|
92
|
+
conn.commit()
|
|
93
|
+
|
|
94
|
+
# Create shadow table (seal_outcomes would do this, but we do it directly)
|
|
95
|
+
masked = f"raw_masked_{study_id}"
|
|
96
|
+
conn.execute(f"""
|
|
97
|
+
CREATE TABLE IF NOT EXISTS {masked} (
|
|
98
|
+
"row_id" INTEGER PRIMARY KEY REFERENCES {raw}(row_id),
|
|
99
|
+
"response" TEXT
|
|
100
|
+
)
|
|
101
|
+
""")
|
|
102
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
103
|
+
conn.execute(f"INSERT INTO {masked} (row_id, response) SELECT row_id, response FROM {raw}")
|
|
104
|
+
conn.commit()
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
tests = [{"variable_name": "response", "test_name": "chi_square"}]
|
|
108
|
+
warnings = check_assumptions(study_id, tests)
|
|
109
|
+
|
|
110
|
+
assert len(warnings) >= 1, f"Expected at least one warning, got: {warnings}"
|
|
111
|
+
|
|
112
|
+
# Verify the warning message doesn't leak specific category or arm names
|
|
113
|
+
msg = warnings[0]
|
|
114
|
+
assert "response" in msg # variable name is fine
|
|
115
|
+
# The warning may include example collapse like "CR+PR" - that's intentional guidance
|
|
116
|
+
# Just ensure arm labels don't appear
|
|
117
|
+
assert "A" not in msg.split(":")[1] # arm labels should not appear after the colon
|
|
118
|
+
assert "fishers_exact" in msg # recommends alternative
|
|
119
|
+
|
|
120
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_chi_square_sufficient_counts():
|
|
124
|
+
"""200 patients, 2 arms, 3-level outcome → all expected ≥ 5 → no warnings."""
|
|
125
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
126
|
+
import shutil
|
|
127
|
+
|
|
128
|
+
study_id = "test_sufficient"
|
|
129
|
+
if (DATA_ROOT / study_id).exists():
|
|
130
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
131
|
+
|
|
132
|
+
conn = get_connection(study_id)
|
|
133
|
+
init_db(conn)
|
|
134
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
135
|
+
(study_id, "Sufficient", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
136
|
+
raw = f"raw_{study_id}"
|
|
137
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, outcome TEXT, arm TEXT)")
|
|
138
|
+
|
|
139
|
+
# 200 patients: 100 per arm, 3 outcome levels at ~33 each
|
|
140
|
+
for i in range(200):
|
|
141
|
+
arm = "A" if i < 100 else "B"
|
|
142
|
+
outcome = ["X", "Y", "Z"][i % 3]
|
|
143
|
+
conn.execute(f"INSERT INTO {raw} (outcome, arm) VALUES (?, ?)", (outcome, arm))
|
|
144
|
+
|
|
145
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
146
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
147
|
+
(study_id, "outcome", "outcome", "categorical"))
|
|
148
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
149
|
+
(study_id, "arm", "baseline", "categorical"))
|
|
150
|
+
conn.commit()
|
|
151
|
+
|
|
152
|
+
masked = f"raw_masked_{study_id}"
|
|
153
|
+
conn.execute(f"""
|
|
154
|
+
CREATE TABLE IF NOT EXISTS {masked} (
|
|
155
|
+
"row_id" INTEGER PRIMARY KEY REFERENCES {raw}(row_id),
|
|
156
|
+
"outcome" TEXT
|
|
157
|
+
)
|
|
158
|
+
""")
|
|
159
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
160
|
+
conn.execute(f"INSERT INTO {masked} (row_id, outcome) SELECT row_id, outcome FROM {raw}")
|
|
161
|
+
conn.commit()
|
|
162
|
+
conn.close()
|
|
163
|
+
|
|
164
|
+
tests = [{"variable_name": "outcome", "test_name": "chi_square", "group_col": "arm"}]
|
|
165
|
+
warnings = check_assumptions(study_id, tests, group_col="arm")
|
|
166
|
+
|
|
167
|
+
assert len(warnings) == 0, f"Expected no warnings, got: {warnings}"
|
|
168
|
+
|
|
169
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_ttest_normality_warning_small_skewed_n():
|
|
173
|
+
"""Small skewed distribution (n<30) → non-normal → warn."""
|
|
174
|
+
import numpy as np
|
|
175
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
176
|
+
import shutil
|
|
177
|
+
|
|
178
|
+
study_id = "test_ttest_skewed"
|
|
179
|
+
if (DATA_ROOT / study_id).exists():
|
|
180
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
181
|
+
|
|
182
|
+
conn = get_connection(study_id)
|
|
183
|
+
init_db(conn)
|
|
184
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
185
|
+
(study_id, "Skewed", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
186
|
+
raw = f"raw_{study_id}"
|
|
187
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, arm TEXT)")
|
|
188
|
+
|
|
189
|
+
# 20 patients — strongly right-skewed (exponential-ish)
|
|
190
|
+
rng = np.random.default_rng(42)
|
|
191
|
+
vals = rng.exponential(scale=3, size=20).round().astype(int).clip(1, 99)
|
|
192
|
+
for i, v in enumerate(vals):
|
|
193
|
+
arm = "A" if i < 10 else "B"
|
|
194
|
+
conn.execute(f"INSERT INTO {raw} (age, arm) VALUES (?, ?)", (str(v), arm))
|
|
195
|
+
|
|
196
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
197
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
198
|
+
(study_id, "age", "outcome", "continuous"))
|
|
199
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
200
|
+
(study_id, "arm", "baseline", "categorical"))
|
|
201
|
+
conn.commit()
|
|
202
|
+
|
|
203
|
+
# Create shadow table
|
|
204
|
+
masked = f"raw_masked_{study_id}"
|
|
205
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {masked} (row_id INTEGER PRIMARY KEY, age TEXT)")
|
|
206
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
207
|
+
conn.execute(f"INSERT INTO {masked} (row_id, age) SELECT row_id, age FROM {raw}")
|
|
208
|
+
conn.commit()
|
|
209
|
+
conn.close()
|
|
210
|
+
|
|
211
|
+
warnings = check_assumptions(study_id, [{"variable_name": "age", "test_name": "t_test"}])
|
|
212
|
+
assert len(warnings) >= 1, f"Expected warning for skewed data, got: {warnings}"
|
|
213
|
+
msg = warnings[0]
|
|
214
|
+
assert "Shapiro-Wilk" in msg
|
|
215
|
+
assert "mann_whitney" in msg
|
|
216
|
+
assert "arm" not in msg.lower() # no group label leak
|
|
217
|
+
|
|
218
|
+
# Confirm the check did NOT use the group column
|
|
219
|
+
assert "arm" not in msg
|
|
220
|
+
|
|
221
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_ttest_no_warning_large_n():
|
|
225
|
+
"""Large n (>=30) → no warning regardless of shape (CLT robustness)."""
|
|
226
|
+
import numpy as np
|
|
227
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
228
|
+
import shutil
|
|
229
|
+
|
|
230
|
+
study_id = "test_ttest_large"
|
|
231
|
+
if (DATA_ROOT / study_id).exists():
|
|
232
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
233
|
+
|
|
234
|
+
conn = get_connection(study_id)
|
|
235
|
+
init_db(conn)
|
|
236
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
237
|
+
(study_id, "LargeN", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
238
|
+
raw = f"raw_{study_id}"
|
|
239
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, val TEXT, grp TEXT)")
|
|
240
|
+
# 40 strongly skewed values
|
|
241
|
+
rng = np.random.default_rng(42)
|
|
242
|
+
vals = rng.exponential(scale=3, size=40).round().astype(int).clip(1, 99)
|
|
243
|
+
for i, v in enumerate(vals):
|
|
244
|
+
g = "A" if i < 20 else "B"
|
|
245
|
+
conn.execute(f"INSERT INTO {raw} (val, grp) VALUES (?, ?)", (str(v), g))
|
|
246
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
247
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
248
|
+
(study_id, "val", "outcome", "continuous"))
|
|
249
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
250
|
+
(study_id, "grp", "baseline", "categorical"))
|
|
251
|
+
conn.commit()
|
|
252
|
+
masked = f"raw_masked_{study_id}"
|
|
253
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {masked} (row_id INTEGER PRIMARY KEY, val TEXT)")
|
|
254
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
255
|
+
conn.execute(f"INSERT INTO {masked} (row_id, val) SELECT row_id, val FROM {raw}")
|
|
256
|
+
conn.commit()
|
|
257
|
+
conn.close()
|
|
258
|
+
|
|
259
|
+
warnings = check_assumptions(study_id, [{"variable_name": "val", "test_name": "t_test"}])
|
|
260
|
+
assert len(warnings) == 0, f"Expected no warning for large n, got: {warnings}"
|
|
261
|
+
|
|
262
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_ttest_no_warning_normal_small_n():
|
|
266
|
+
"""Small n but genuinely normal data → no warning."""
|
|
267
|
+
import numpy as np
|
|
268
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
269
|
+
import shutil
|
|
270
|
+
|
|
271
|
+
study_id = "test_ttest_normal"
|
|
272
|
+
if (DATA_ROOT / study_id).exists():
|
|
273
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
274
|
+
|
|
275
|
+
conn = get_connection(study_id)
|
|
276
|
+
init_db(conn)
|
|
277
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
278
|
+
(study_id, "Normal", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
279
|
+
raw = f"raw_{study_id}"
|
|
280
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, val TEXT, grp TEXT)")
|
|
281
|
+
rng = np.random.default_rng(42)
|
|
282
|
+
vals = rng.normal(loc=50, scale=10, size=20).round().astype(int).clip(1, 99)
|
|
283
|
+
for i, v in enumerate(vals):
|
|
284
|
+
g = "A" if i < 10 else "B"
|
|
285
|
+
conn.execute(f"INSERT INTO {raw} (val, grp) VALUES (?, ?)", (str(v), g))
|
|
286
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
287
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
288
|
+
(study_id, "val", "outcome", "continuous"))
|
|
289
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
290
|
+
(study_id, "grp", "baseline", "categorical"))
|
|
291
|
+
conn.commit()
|
|
292
|
+
masked = f"raw_masked_{study_id}"
|
|
293
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {masked} (row_id INTEGER PRIMARY KEY, val TEXT)")
|
|
294
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
295
|
+
conn.execute(f"INSERT INTO {masked} (row_id, val) SELECT row_id, val FROM {raw}")
|
|
296
|
+
conn.commit()
|
|
297
|
+
conn.close()
|
|
298
|
+
|
|
299
|
+
warnings = check_assumptions(study_id, [{"variable_name": "val", "test_name": "t_test"}])
|
|
300
|
+
assert len(warnings) == 0, f"Expected no warning for normal data, got: {warnings}"
|
|
301
|
+
|
|
302
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_ttest_warning_uses_pooled_not_grouped():
|
|
306
|
+
"""Confirm the check never queries group_col — adversarial.
|
|
307
|
+
|
|
308
|
+
The warning message should contain no group label and the SQL path
|
|
309
|
+
should not reference the group column.
|
|
310
|
+
"""
|
|
311
|
+
import numpy as np
|
|
312
|
+
import sqlite3
|
|
313
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
314
|
+
import shutil
|
|
315
|
+
|
|
316
|
+
study_id = "test_ttest_pooled"
|
|
317
|
+
if (DATA_ROOT / study_id).exists():
|
|
318
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
319
|
+
|
|
320
|
+
conn = get_connection(study_id)
|
|
321
|
+
init_db(conn)
|
|
322
|
+
conn.execute("INSERT OR REPLACE INTO studies (id,name,created_at,data_dir,study_type) VALUES (?,?,?,?,?)",
|
|
323
|
+
(study_id, "Pooled", "2025-01-01", str(DATA_ROOT / study_id), "cohort"))
|
|
324
|
+
raw = f"raw_{study_id}"
|
|
325
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, outcome TEXT, weird_arm TEXT)")
|
|
326
|
+
rng = np.random.default_rng(42)
|
|
327
|
+
vals = rng.exponential(scale=3, size=15).round().astype(int).clip(1, 99)
|
|
328
|
+
for i, v in enumerate(vals):
|
|
329
|
+
conn.execute(f"INSERT INTO {raw} (outcome, weird_arm) VALUES (?, ?)", (str(v), "X"))
|
|
330
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (study_id,))
|
|
331
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
332
|
+
(study_id, "outcome", "outcome", "continuous"))
|
|
333
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
334
|
+
(study_id, "weird_arm", "baseline", "categorical"))
|
|
335
|
+
conn.commit()
|
|
336
|
+
masked = f"raw_masked_{study_id}"
|
|
337
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {masked} (row_id INTEGER PRIMARY KEY, outcome TEXT)")
|
|
338
|
+
conn.execute(f"DELETE FROM {masked}")
|
|
339
|
+
conn.execute(f"INSERT INTO {masked} (row_id, outcome) SELECT row_id, outcome FROM {raw}")
|
|
340
|
+
conn.commit()
|
|
341
|
+
conn.close()
|
|
342
|
+
|
|
343
|
+
# Pass a deliberately unusual group_col name — the check should NOT reference it
|
|
344
|
+
warnings = check_assumptions(study_id, [{"variable_name": "outcome", "test_name": "t_test"}],
|
|
345
|
+
group_col="weird_arm")
|
|
346
|
+
# We just want to verify no group label leak
|
|
347
|
+
for w in warnings:
|
|
348
|
+
assert "weird_arm" not in w, f"Group label leaked: {w}"
|
|
349
|
+
|
|
350
|
+
shutil.rmtree(DATA_ROOT / study_id)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for variable classification."""
|
|
2
|
+
|
|
3
|
+
from core.ingestion.variable_classifier import classify_variables_interactive
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_classify_age_continuous():
|
|
7
|
+
"""Age should be classified as continuous baseline."""
|
|
8
|
+
result = classify_variables_interactive("test", ["age"])
|
|
9
|
+
assert result[0]["role"] == "baseline"
|
|
10
|
+
assert result[0]["data_type"] == "continuous"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_classify_outcome_response():
|
|
14
|
+
"""Response should be classified as categorical outcome."""
|
|
15
|
+
result = classify_variables_interactive("test", ["response"])
|
|
16
|
+
assert result[0]["role"] == "outcome"
|
|
17
|
+
assert result[0]["data_type"] == "categorical"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_classify_pfs_time_to_event():
|
|
21
|
+
"""pfs_days should be classified as time_to_event outcome."""
|
|
22
|
+
result = classify_variables_interactive("test", ["pfs_days"])
|
|
23
|
+
assert result[0]["role"] == "outcome"
|
|
24
|
+
assert result[0]["data_type"] == "time_to_event"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_classify_sex_categorical():
|
|
28
|
+
"""Sex should be classified as categorical baseline."""
|
|
29
|
+
result = classify_variables_interactive("test", ["sex"])
|
|
30
|
+
assert result[0]["role"] == "baseline"
|
|
31
|
+
assert result[0]["data_type"] == "categorical"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_classify_iss_stage_categorical():
|
|
35
|
+
"""ISS stage should be categorical baseline."""
|
|
36
|
+
result = classify_variables_interactive("test", ["iss_stage"])
|
|
37
|
+
assert result[0]["role"] == "baseline"
|
|
38
|
+
assert result[0]["data_type"] == "categorical"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_multiple_columns_all_classified():
|
|
42
|
+
"""All columns in a list should get a classification."""
|
|
43
|
+
cols = ["age", "sex", "response", "pfs_days", "os_days", "os_event"]
|
|
44
|
+
result = classify_variables_interactive("test", cols)
|
|
45
|
+
assert len(result) == len(cols)
|
|
46
|
+
names = [r["column"] for r in result]
|
|
47
|
+
assert names == cols
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_patient_id_skipped():
|
|
51
|
+
"""Identifier columns (id, patient, name) should be skipped."""
|
|
52
|
+
result = classify_variables_interactive("test", ["patient_id", "age"])
|
|
53
|
+
assert result[0]["column"] == "age" # patient_id was skipped
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_prior_lines_continuous():
|
|
57
|
+
"""prior_lines should be classified as continuous baseline (count data)."""
|
|
58
|
+
result = classify_variables_interactive("test", ["prior_lines"])
|
|
59
|
+
assert result[0]["role"] == "baseline"
|
|
60
|
+
assert result[0]["data_type"] == "continuous"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_float_column_continuous():
|
|
64
|
+
"""Float column with many distinct values → continuous regardless of name."""
|
|
65
|
+
import shutil, tempfile, csv
|
|
66
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
67
|
+
from core.ingestion.variable_classifier import classify_variables_interactive
|
|
68
|
+
|
|
69
|
+
sid = "test_float_col"
|
|
70
|
+
p = DATA_ROOT / sid
|
|
71
|
+
if p.exists():
|
|
72
|
+
shutil.rmtree(p)
|
|
73
|
+
|
|
74
|
+
conn = get_connection(sid)
|
|
75
|
+
init_db(conn)
|
|
76
|
+
conn.execute("INSERT OR REPLACE INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
|
|
77
|
+
(sid, "FloatCol", "2025-01-01", str(p)))
|
|
78
|
+
raw = f"raw_{sid}"
|
|
79
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, followup_m_protein TEXT)")
|
|
80
|
+
for i in range(20):
|
|
81
|
+
conn.execute(f"INSERT INTO {raw} (followup_m_protein) VALUES (?)", (str(i * 0.5 + 0.1),))
|
|
82
|
+
conn.commit()
|
|
83
|
+
conn.close()
|
|
84
|
+
|
|
85
|
+
result = classify_variables_interactive(sid, ["followup_m_protein"])
|
|
86
|
+
# "followup" is in the outcome keyword set, so role should be outcome
|
|
87
|
+
assert result[0]["role"] == "outcome"
|
|
88
|
+
assert result[0]["data_type"] == "continuous"
|
|
89
|
+
|
|
90
|
+
shutil.rmtree(p)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_low_cardinality_categorical():
|
|
94
|
+
"""Column with 3 distinct string values → categorical."""
|
|
95
|
+
import shutil
|
|
96
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
97
|
+
|
|
98
|
+
sid = "test_low_card"
|
|
99
|
+
p = DATA_ROOT / sid
|
|
100
|
+
if p.exists():
|
|
101
|
+
shutil.rmtree(p)
|
|
102
|
+
|
|
103
|
+
conn = get_connection(sid)
|
|
104
|
+
init_db(conn)
|
|
105
|
+
conn.execute("INSERT OR REPLACE INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
|
|
106
|
+
(sid, "LowCard", "2025-01-01", str(p)))
|
|
107
|
+
raw = f"raw_{sid}"
|
|
108
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, grade TEXT)")
|
|
109
|
+
for i in range(20):
|
|
110
|
+
conn.execute(f"INSERT INTO {raw} (grade) VALUES (?)",
|
|
111
|
+
(["mild", "moderate", "severe"][i % 3],))
|
|
112
|
+
conn.commit()
|
|
113
|
+
conn.close()
|
|
114
|
+
|
|
115
|
+
result = classify_variables_interactive(sid, ["grade"])
|
|
116
|
+
assert result[0]["data_type"] == "categorical"
|
|
117
|
+
|
|
118
|
+
shutil.rmtree(p)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_unknown_name_flagged_unclassified():
|
|
122
|
+
"""Column with unrecognized column name → unclassified."""
|
|
123
|
+
result = classify_variables_interactive("test", ["xyz_something_random"])
|
|
124
|
+
assert result[0]["role"] == "unclassified"
|