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,498 @@
1
+ """Tests for Cox PH plan parsing and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import random
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import pytest
15
+
16
+ from core.cli.main import cmd_plan, cmd_analyze, cmd_lock
17
+ from core.database import DATA_ROOT, get_connection, init_db
18
+ from core.planning.study_plan import StudyPlan
19
+ from core.planning.lock import lock_plan
20
+ from core.masking.gate import unmask_study
21
+ from core.stats.inferential import _cox_ph_model
22
+
23
+ STUDY_ID = "test_cox_plan"
24
+ RAW = f"raw_{STUDY_ID}"
25
+
26
+
27
+ def _setup_db(extra_rows: list[tuple] | None = None):
28
+ study_path = DATA_ROOT / STUDY_ID
29
+ if study_path.exists():
30
+ shutil.rmtree(study_path)
31
+ study_path.mkdir(parents=True)
32
+ conn = get_connection(STUDY_ID)
33
+ init_db(conn)
34
+ conn.execute(
35
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
36
+ (STUDY_ID, "Cox PH Plan Test", "2025-01-01", str(study_path), "cohort"),
37
+ )
38
+ conn.execute(
39
+ f"CREATE TABLE IF NOT EXISTS {RAW} "
40
+ f"(row_id INTEGER PRIMARY KEY, treatment_arm TEXT, pfs_days TEXT, pfs_event TEXT, age TEXT)"
41
+ )
42
+ conn.executemany(
43
+ f"INSERT INTO {RAW} (treatment_arm, pfs_days, pfs_event, age) VALUES (?, ?, ?, ?)",
44
+ extra_rows or [
45
+ ("A", "100", "1", "65"),
46
+ ("A", "200", "0", "70"),
47
+ ("B", "150", "1", "55"),
48
+ ("B", "300", "0", "60"),
49
+ ],
50
+ )
51
+ conn.executemany(
52
+ "INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) VALUES (?, ?, ?, ?, ?)",
53
+ [
54
+ (1, STUDY_ID, "treatment_arm", "baseline", "categorical"),
55
+ (2, STUDY_ID, "pfs_days", "outcome", "time_to_event"),
56
+ (3, STUDY_ID, "pfs_event", "outcome", "categorical"),
57
+ (4, STUDY_ID, "age", "baseline", "continuous"),
58
+ ],
59
+ )
60
+ conn.commit()
61
+ conn.close()
62
+
63
+
64
+ def _plan_args(cox_ph_models=None, **kwargs):
65
+ values = dict(
66
+ study_id=STUDY_ID,
67
+ outcome_var_ids="3",
68
+ tests=[],
69
+ covariates="",
70
+ study_type="cohort",
71
+ comparison="PFS by treatment arm",
72
+ overrides=[],
73
+ matching_criteria="",
74
+ )
75
+ values["cox_ph_models"] = cox_ph_models or []
76
+ values.update(kwargs)
77
+ return argparse.Namespace(**values)
78
+
79
+
80
+ # --- valid ID/name resolution ---
81
+
82
+
83
+ def test_cox_ph_accepts_valid_column_names(capsys):
84
+ _setup_db()
85
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:age:test rationale"]))
86
+ prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
87
+ assert prov.exists()
88
+
89
+
90
+ def test_cox_ph_accepts_4_part_format(capsys):
91
+ _setup_db()
92
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
93
+ prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
94
+ assert prov.exists()
95
+
96
+
97
+ def test_cox_ph_accepts_5_part_format_no_rationale(capsys):
98
+ _setup_db()
99
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:age"]))
100
+ prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
101
+ assert prov.exists()
102
+
103
+
104
+ # --- invalid column name rejected at plan ---
105
+
106
+
107
+ def test_cox_ph_rejects_unknown_column(capsys):
108
+ _setup_db()
109
+ with pytest.raises(SystemExit):
110
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:nonexistent_col:treatment_arm"]))
111
+ err = capsys.readouterr().err
112
+ assert "nonexistent_col" in err
113
+ assert "not found" in err
114
+
115
+
116
+ def test_cox_ph_rejects_unknown_covariate(capsys):
117
+ _setup_db()
118
+ with pytest.raises(SystemExit):
119
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:fake_cov"]))
120
+ err = capsys.readouterr().err
121
+ assert "fake_cov" in err
122
+ assert "not found" in err
123
+
124
+
125
+ # --- non-binary event_col rejected at plan ---
126
+
127
+
128
+ def test_cox_ph_rejects_non_binary_event(capsys):
129
+ _setup_db(extra_rows=[
130
+ ("A", "100", "maybe", "65"),
131
+ ("B", "150", "yes", "55"),
132
+ ])
133
+ with pytest.raises(SystemExit):
134
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
135
+ err = capsys.readouterr().err
136
+ assert "binary" in err.lower()
137
+ assert "maybe" in err or "yes" in err
138
+
139
+
140
+ def test_cox_ph_accepts_binary_event(capsys):
141
+ _setup_db(extra_rows=[
142
+ ("A", "100", "1", "65"),
143
+ ("B", "150", "0", "55"),
144
+ ])
145
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
146
+ prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
147
+ assert prov.exists()
148
+
149
+
150
+ # --- negative survival_time rejected at plan ---
151
+
152
+
153
+ def test_cox_ph_rejects_negative_survival_time(capsys):
154
+ _setup_db(extra_rows=[
155
+ ("A", "-5", "1", "65"),
156
+ ("B", "150", "0", "55"),
157
+ ])
158
+ with pytest.raises(SystemExit):
159
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
160
+ err = capsys.readouterr().err
161
+ assert "negative" in err.lower()
162
+
163
+
164
+ def test_cox_ph_rejects_non_numeric_survival_time(capsys):
165
+ _setup_db(extra_rows=[
166
+ ("A", "abc", "1", "65"),
167
+ ("B", "150", "0", "55"),
168
+ ])
169
+ with pytest.raises(SystemExit):
170
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
171
+ err = capsys.readouterr().err
172
+ assert "not numeric" in err.lower()
173
+
174
+
175
+ # --- short-form 4-part format accepted ---
176
+
177
+
178
+ def test_cox_ph_4_part_no_covariates_no_rationale(capsys):
179
+ _setup_db()
180
+ cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
181
+ prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
182
+ assert prov.exists()
183
+ import json
184
+ plan = json.loads(prov.read_text())
185
+ m = plan["cox_ph_models"][0]
186
+ assert m["model_name"] == "m1"
187
+ assert m["survival_time_col"] == "pfs_days"
188
+ assert m["event_col"] == "pfs_event"
189
+ assert m["primary_treatment_col"] == "treatment_arm"
190
+ assert m["covariate_cols"] == []
191
+ assert m["rationale"] == ""
192
+
193
+
194
+ # --- regression: locked-plan reload with CoxPHModel dataclass ---
195
+
196
+
197
+ def _seed_large_dataset(study_id: str, n: int = 25):
198
+ """Seed N rows into raw table so EPV check passes."""
199
+ import random
200
+ rng = random.Random(42)
201
+ conn = get_connection(study_id)
202
+ raw = f"raw_{study_id}"
203
+ rows = []
204
+ for i in range(n):
205
+ arm = rng.choice(["A", "B"])
206
+ pfs = int(max(30, rng.expovariate(1/200)))
207
+ evt = rng.choices(["0", "1"], weights=[30, 70])[0]
208
+ rows.append((arm, str(pfs), evt))
209
+ conn.executemany(
210
+ f"INSERT INTO {raw} (treatment_arm, pfs_days, pfs_event) "
211
+ f"VALUES (?, ?, ?)",
212
+ rows,
213
+ )
214
+ conn.commit()
215
+ conn.close()
216
+
217
+
218
+ def test_cox_ph_regression_locked_plan_reload():
219
+ """Plan → lock → reload (StudyPlan.from_dict) → unmask → analyze.
220
+
221
+ Regression: after loading a locked plan from disk, cox_ph_models entries
222
+ are CoxPHModel dataclass instances. cmd_analyze must handle them via
223
+ _model_field helper, not .get().
224
+ """
225
+ sid = "test_cox_reload_regression"
226
+ study_path = DATA_ROOT / sid
227
+ if study_path.exists():
228
+ shutil.rmtree(study_path)
229
+ study_path.mkdir(parents=True)
230
+ conn = get_connection(sid)
231
+ init_db(conn)
232
+ conn.execute(
233
+ "INSERT INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
234
+ (sid, "Cox PH Reload Regression", "2025-01-01", str(study_path), "cohort"),
235
+ )
236
+ raw = f"raw_{sid}"
237
+ conn.execute(f"CREATE TABLE {raw} "
238
+ f"(row_id INTEGER PRIMARY KEY, treatment_arm TEXT, pfs_days TEXT, "
239
+ f"pfs_event TEXT, age TEXT, high_risk_fish TEXT, prior_lines TEXT)")
240
+ conn.executemany(
241
+ "INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) VALUES (?, ?, ?, ?, ?)",
242
+ [
243
+ (1, sid, "treatment_arm", "baseline", "categorical"),
244
+ (2, sid, "pfs_days", "outcome", "time_to_event"),
245
+ (3, sid, "pfs_event", "outcome", "categorical"),
246
+ ],
247
+ )
248
+ conn.commit()
249
+ conn.close()
250
+
251
+ _seed_large_dataset(sid, n=25)
252
+
253
+ # Seal outcomes so the masked shadow table exists at plan time
254
+ from core.masking.gate import seal_outcomes
255
+ seal_outcomes(sid)
256
+
257
+ # Plan with a Cox PH model (treatment_arm only, no extra covariates — avoids singular matrix with sparse levels)
258
+ plan_ns = argparse.Namespace(
259
+ study_id=sid,
260
+ outcome_var_ids="3",
261
+ tests=[],
262
+ covariates="",
263
+ study_type="cohort",
264
+ comparison="PFS by treatment arm",
265
+ overrides=[],
266
+ matching_criteria="",
267
+ cox_ph_models=["pfs_simple:pfs_days:pfs_event:treatment_arm"],
268
+ )
269
+ cmd_plan(plan_ns)
270
+
271
+ lock_ns = argparse.Namespace(study_id=sid, allow_duplicate_ids=False)
272
+ cmd_lock(lock_ns)
273
+
274
+ # Now reload the locked plan from disk as a fresh StudyPlan
275
+ locked_path = sorted(study_path.glob("study_plan.v*.locked.json"))[-1]
276
+ plan_data = json.loads(locked_path.read_text())
277
+ reloaded_plan = StudyPlan.from_dict(plan_data)
278
+ # Verify cox_ph_models entries are CoxPHModel instances
279
+ for m in reloaded_plan.cox_ph_models:
280
+ assert not isinstance(m, dict), "cox_ph_models must be CoxPHModel after from_dict, not dict"
281
+ from core.planning.study_plan import CoxPHModel
282
+ assert isinstance(m, CoxPHModel)
283
+
284
+ # Manually lock the reloaded plan to mimic what cmd_analyze does (load_plan)
285
+ path = lock_plan(sid, reloaded_plan)
286
+ assert path.exists()
287
+
288
+ # Unmask
289
+ unmask_study(sid)
290
+
291
+ # Analyze with --force to bypass EPV warning (EPV < 10 with small N)
292
+ analyze_ns = argparse.Namespace(
293
+ study_id=sid,
294
+ force=True,
295
+ post_hoc=False,
296
+ rerun=False,
297
+ )
298
+ cmd_analyze(analyze_ns)
299
+ # If we get here without AttributeError, the regression is fixed.
300
+ # Check results in DB
301
+ conn = get_connection(sid)
302
+ cur = conn.execute(
303
+ "SELECT id, test_name, statistic, p_value FROM analysis_results "
304
+ "WHERE study_id=? AND test_name=? ORDER BY id DESC LIMIT 1",
305
+ (sid, "cox_ph_model"),
306
+ )
307
+ row = cur.fetchone()
308
+ conn.close()
309
+ assert row is not None, "No Cox PH result found in DB"
310
+ assert row["statistic"] is not None, "Cox PH HR should not be None"
311
+ assert row["p_value"] is not None, "Cox PH p-value should not be None"
312
+ assert row["statistic"] > 0, "HR must be positive"
313
+
314
+ shutil.rmtree(study_path)
315
+
316
+
317
+ # ── Interaction term tests with adequate N ──────────────────────────────
318
+
319
+
320
+ def _synthetic_interaction_data(n: int, seed: int,
321
+ true_interaction: bool = True) -> pd.DataFrame:
322
+ """Generate synthetic survival data with a known treatment × subgroup interaction.
323
+
324
+ Parameters
325
+ ----------
326
+ n : int
327
+ Number of patients.
328
+ seed : int
329
+ Random seed for reproducibility.
330
+ true_interaction : bool
331
+ If True, treatment effect differs by subgroup (HR_subgroup_B = 1.0).
332
+ If False, treatment effect is same across subgroups (no interaction).
333
+
334
+ Returns
335
+ -------
336
+ pd.DataFrame with columns:
337
+ pfs_days, pfs_event, treatment_arm, high_risk_fish, age
338
+ """
339
+ rng = random.Random(seed)
340
+ np_rng = np.random.RandomState(seed)
341
+
342
+ data = []
343
+ for _ in range(n):
344
+ arm = rng.choice(["A", "B"])
345
+ fish = rng.choices(["yes", "no"], weights=[0.4, 0.6])[0]
346
+ age = rng.randint(40, 85)
347
+
348
+ # Baseline hazard: exp(-t/200)
349
+ # Base HR for treatment (arm B vs A) — always present
350
+ if arm == "B":
351
+ base_hr = 0.5 # treatment halves hazard
352
+ else:
353
+ base_hr = 1.0
354
+
355
+ # Interaction: in high-risk subgroup, treatment effect differs
356
+ if fish == "yes" and arm == "B":
357
+ if true_interaction:
358
+ fish_hr = 2.0 # treatment loses effectiveness in high-risk
359
+ else:
360
+ fish_hr = 1.0 # no interaction, same effect
361
+ else:
362
+ fish_hr = 1.0
363
+
364
+ hr = base_hr * fish_hr
365
+
366
+ # Generate survival time from exponential with rate=hr/200
367
+ t = np_rng.exponential(200.0 / hr)
368
+ pfs_days = max(1, int(t))
369
+
370
+ # Censor at 500 days
371
+ event = 1 if pfs_days < 500 else 0
372
+ pfs_days = min(pfs_days, 500)
373
+
374
+ data.append((pfs_days, event, arm, fish, age))
375
+
376
+ return pd.DataFrame(data, columns=["pfs_days", "pfs_event",
377
+ "treatment_arm", "high_risk_fish", "age"])
378
+
379
+
380
+ class TestInteractionTerms:
381
+ """Verify Cox PH interaction term detection with adequate power."""
382
+
383
+ def test_true_interaction_detected(self):
384
+ """Model with interaction should recover significant interaction term (N=400)."""
385
+ df = _synthetic_interaction_data(400, seed=42, true_interaction=True)
386
+ result = _cox_ph_model(
387
+ df,
388
+ time_col="pfs_days",
389
+ event_col="pfs_event",
390
+ group_col="treatment_arm",
391
+ covariates=["high_risk_fish", "age"],
392
+ interaction_terms=[["treatment_arm", "high_risk_fish"]],
393
+ )
394
+ cov_results = result.get("params", {}).get("per_covariate_results", [])
395
+ int_rows = [c for c in cov_results if "(interaction)" in c.get("covariate", "")]
396
+ assert len(int_rows) > 0, "No interaction term found in results"
397
+ int_row = int_rows[0]
398
+ assert int_row["hr"] is not None, "Interaction HR should not be None"
399
+ assert int_row["wald_p"] is not None, "Interaction p-value should not be None"
400
+ # With true interaction built in and N=400, p should be significant
401
+ assert int_row["wald_p"] < 0.05, (
402
+ f"True interaction should be detected (p={int_row['wald_p']:.4f})"
403
+ )
404
+
405
+ def test_no_interaction_not_detected(self):
406
+ """Model with no interaction should have non-significant interaction term."""
407
+ df = _synthetic_interaction_data(400, seed=43, true_interaction=False)
408
+ result = _cox_ph_model(
409
+ df,
410
+ time_col="pfs_days",
411
+ event_col="pfs_event",
412
+ group_col="treatment_arm",
413
+ covariates=["high_risk_fish", "age"],
414
+ interaction_terms=[["treatment_arm", "high_risk_fish"]],
415
+ )
416
+ cov_results = result.get("params", {}).get("per_covariate_results", [])
417
+ int_rows = [c for c in cov_results if "(interaction)" in c.get("covariate", "")]
418
+ assert len(int_rows) > 0, "No interaction term found in results"
419
+ int_row = int_rows[0]
420
+ # With no interaction, CI should cross 1
421
+ ci_lo = int_row.get("ci_lower")
422
+ ci_hi = int_row.get("ci_upper")
423
+ if ci_lo is not None and ci_hi is not None:
424
+ assert ci_lo <= 1 <= ci_hi, (
425
+ f"No-interaction CI ({ci_lo:.3f}, {ci_hi:.3f}) should cross 1"
426
+ )
427
+
428
+ def test_epv_counts_interaction_as_predictor(self):
429
+ """EPV check should count interaction terms as additional predictors."""
430
+ from core.planning.test_selector import check_cox_ph_model_assumptions
431
+
432
+ # Build a small dataset so EPV < 10 is triggered
433
+ study_id = "test_epv_interaction"
434
+ study_path = DATA_ROOT / study_id
435
+ if study_path.exists():
436
+ shutil.rmtree(study_path)
437
+ study_path.mkdir(parents=True)
438
+ conn = get_connection(study_id)
439
+ init_db(conn)
440
+ conn.execute(
441
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
442
+ (study_id, "EPV Interaction Test", "2025-01-01", str(study_path), "cohort"),
443
+ )
444
+ conn.execute(
445
+ f"CREATE TABLE IF NOT EXISTS raw_masked_{study_id} AS "
446
+ "SELECT * FROM (SELECT 'A' AS treatment_arm, 'yes' AS high_risk_fish, "
447
+ "'1' AS pfs_event, 100 AS pfs_days, 50 AS age) WHERE 1=0"
448
+ )
449
+ # Insert 5 events across 4 patients → EPV=5/3≈1.7 with interaction
450
+ rows = [
451
+ ("A", "yes", "1", 100, 50),
452
+ ("A", "no", "0", 200, 55),
453
+ ("B", "yes", "1", 80, 60),
454
+ ("B", "no", "1", 150, 45),
455
+ ]
456
+ for r in rows:
457
+ conn.execute(
458
+ f"INSERT INTO raw_masked_{study_id} "
459
+ f"(treatment_arm, high_risk_fish, pfs_event, pfs_days, age) "
460
+ f"VALUES (?, ?, ?, ?, ?)", r
461
+ )
462
+ conn.execute(
463
+ "INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
464
+ "VALUES (1, ?, 'treatment_arm', 'baseline', 'categorical')",
465
+ (study_id,),
466
+ )
467
+ conn.execute(
468
+ "INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
469
+ "VALUES (2, ?, 'pfs_days', 'outcome', 'time_to_event')",
470
+ (study_id,),
471
+ )
472
+ conn.execute(
473
+ "INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
474
+ "VALUES (3, ?, 'pfs_event', 'outcome', 'categorical')",
475
+ (study_id,),
476
+ )
477
+ conn.commit()
478
+ conn.close()
479
+
480
+ model = {
481
+ "model_name": "test_interaction",
482
+ "survival_time_col": "pfs_days",
483
+ "event_col": "pfs_event",
484
+ "primary_treatment_col": "treatment_arm",
485
+ "covariate_cols": ["high_risk_fish"],
486
+ "interaction_terms": [["treatment_arm", "high_risk_fish"]],
487
+ }
488
+ warnings = check_cox_ph_model_assumptions(study_id, [model])
489
+ warning_text = " ".join(warnings)
490
+ # The warning should mention 3 predictors (treatment + 1 covariate + 1 interaction)
491
+ assert "3 predictor" in warning_text, (
492
+ f"EPV warning should count 3 predictors: {warning_text}"
493
+ )
494
+ assert "EPV=" in warning_text, (
495
+ "EPV warning should fire when interaction adds a predictor"
496
+ )
497
+
498
+ shutil.rmtree(study_path)