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,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"