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,49 @@
1
+ """Adversarial tests for post-hoc tagging.
2
+
3
+ Any analysis not in the locked plan gets tagged EXPLORATORY_POST_HOC
4
+ everywhere downstream. The tag must be impossible to strip without
5
+ creating a new object.
6
+ """
7
+
8
+ from core.stats.post_hoc import run_post_hoc, PostHocResult, EXPLORATORY_POST_HOC
9
+
10
+
11
+ def test_post_hoc_result_has_tag():
12
+ r = PostHocResult(test_name="test", statistic=1.0, p_value=0.05)
13
+ assert r.tag == EXPLORATORY_POST_HOC
14
+ assert r.is_post_hoc() is True
15
+
16
+
17
+ def test_tag_survives_to_dict():
18
+ r = PostHocResult(test_name="test", statistic=1.0, p_value=0.05)
19
+ d = r.to_dict()
20
+ assert d["tag"] == EXPLORATORY_POST_HOC
21
+
22
+
23
+ def test_tag_not_silently_removable():
24
+ """The tag is a class attribute frozen at creation."""
25
+ r = PostHocResult(test_name="test", statistic=1.0, p_value=0.05)
26
+ d = r.to_dict()
27
+ assert d["tag"] is not None
28
+ assert d["tag"] == "EXPLORATORY_POST_HOC"
29
+
30
+
31
+ def test_run_post_hoc_wraps_result():
32
+ def fake_test():
33
+ return {"test_name": "chi_square", "statistic": 3.84, "p_value": 0.05,
34
+ "ci_lower": None, "ci_upper": None, "params": {}}
35
+ r = run_post_hoc(fake_test)
36
+ assert isinstance(r, PostHocResult)
37
+ assert r.tag == EXPLORATORY_POST_HOC
38
+ assert r.test_name == "chi_square"
39
+
40
+
41
+ def test_multiple_post_hoc_results():
42
+ """Multiple post-hoc results each carry their own tag."""
43
+ results = [
44
+ PostHocResult(test_name="a", statistic=1.0, p_value=0.05),
45
+ PostHocResult(test_name="b", statistic=2.0, p_value=0.01),
46
+ ]
47
+ for r in results:
48
+ assert r.tag == EXPLORATORY_POST_HOC
49
+ assert "EXPLORATORY_POST_HOC" in r.to_dict()["tag"]
@@ -0,0 +1,203 @@
1
+ """Tests for post-hoc amendment execution and deduplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from core.database import get_connection, init_db, DATA_ROOT
12
+ from core.masking.gate import seal_outcomes, unmask_study
13
+ from core.planning.study_plan import StudyPlan, CoxPHModel
14
+ from core.planning.lock import lock_plan, lock_amendment, load_plan
15
+ from core.cli.main import cmd_analyze
16
+ import argparse
17
+
18
+
19
+ STUDY_ID = "test_posthoc"
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def _setup():
24
+ p = DATA_ROOT / STUDY_ID
25
+ if p.exists():
26
+ shutil.rmtree(p)
27
+ p.mkdir(parents=True)
28
+
29
+ conn = get_connection(STUDY_ID)
30
+ init_db(conn)
31
+ conn.execute(
32
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
33
+ "VALUES (?, ?, ?, ?, ?, ?)",
34
+ (STUDY_ID, "Post-hoc Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
35
+ )
36
+ raw = f"raw_{STUDY_ID}"
37
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, pfs_days TEXT, pfs_event TEXT, treatment_arm TEXT)")
38
+ for i in range(20):
39
+ arm = "A" if i % 2 == 0 else "B"
40
+ event = "1" if i % 3 == 0 else "0"
41
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES (?, ?, ?, ?)",
42
+ (str(30 + i), str(100 + i * 5), event, arm))
43
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
44
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
45
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_days', 'outcome', 'time_to_event')", (STUDY_ID,))
46
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_event', 'outcome', 'categorical')", (STUDY_ID,))
47
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
48
+ conn.commit()
49
+ conn.close()
50
+
51
+ seal_outcomes(STUDY_ID)
52
+ yield
53
+ if p.exists():
54
+ shutil.rmtree(p)
55
+
56
+
57
+ def _set_state(state: int):
58
+ conn = get_connection(STUDY_ID)
59
+ init_db(conn)
60
+ conn.execute("UPDATE studies SET is_locked=? WHERE id=?", (state, STUDY_ID))
61
+ conn.commit()
62
+ conn.close()
63
+
64
+
65
+ def test_posthoc_test_executed_by_analyze():
66
+ """Post-hoc tests added via amend must be executed by analyze without --post-hoc flag."""
67
+ _set_state(1)
68
+ plan = StudyPlan(
69
+ study_id=STUDY_ID,
70
+ study_type="cohort",
71
+ primary_comparison="PFS by arm",
72
+ planned_tests=[],
73
+ )
74
+ lock_plan(STUDY_ID, plan)
75
+ _set_state(2) # unmasked
76
+ unmask_study(STUDY_ID)
77
+
78
+ lock_amendment(
79
+ STUDY_ID,
80
+ amendment_reason="Add logrank test",
81
+ post_hoc_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "Post-hoc KM"}],
82
+ )
83
+
84
+ args = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
85
+ cmd_analyze(args)
86
+
87
+ conn = get_connection(STUDY_ID)
88
+ rows = conn.execute(
89
+ "SELECT test_name, is_pre_registered, status_json FROM analysis_results WHERE study_id=?",
90
+ (STUDY_ID,),
91
+ ).fetchall()
92
+ conn.close()
93
+
94
+ logrank_results = [r for r in rows if r["test_name"] == "kaplan_meier_logrank"]
95
+ assert len(logrank_results) == 1, f"Expected 1 logrank result, got {len(logrank_results)}"
96
+ assert logrank_results[0]["is_pre_registered"] == 0, "Post-hoc test should have is_pre_registered=0"
97
+ status = json.loads(logrank_results[0]["status_json"])
98
+ assert status["status"] in ("completed", "error"), f"Unexpected status: {status}"
99
+
100
+
101
+ def test_amend_dedup_prevents_duplicate_posthoc_tests():
102
+ """Amending with the same test twice should not create duplicates."""
103
+ _set_state(1)
104
+ plan = StudyPlan(
105
+ study_id=STUDY_ID,
106
+ study_type="cohort",
107
+ primary_comparison="PFS by arm",
108
+ planned_tests=[],
109
+ )
110
+ lock_plan(STUDY_ID, plan)
111
+ _set_state(2)
112
+ unmask_study(STUDY_ID)
113
+
114
+ lock_amendment(
115
+ STUDY_ID,
116
+ amendment_reason="First add",
117
+ post_hoc_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "Post-hoc KM"}],
118
+ )
119
+ lock_amendment(
120
+ STUDY_ID,
121
+ amendment_reason="Second add (duplicate)",
122
+ post_hoc_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "Post-hoc KM"}],
123
+ )
124
+
125
+ latest = load_plan(STUDY_ID)
126
+ logrank_tests = [t for t in latest.post_hoc_tests if t["test_name"] == "kaplan_meier_logrank"]
127
+ assert len(logrank_tests) == 1, f"Expected 1 logrank test after dedup, got {len(logrank_tests)}"
128
+
129
+
130
+ def test_planned_and_posthoc_both_executed():
131
+ """analyze should run both planned_tests and post_hoc_tests in a single call."""
132
+ _set_state(1)
133
+ plan = StudyPlan(
134
+ study_id=STUDY_ID,
135
+ study_type="cohort",
136
+ primary_comparison="PFS by arm",
137
+ planned_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "Pre-registered KM"}],
138
+ )
139
+ lock_plan(STUDY_ID, plan)
140
+ _set_state(2)
141
+ unmask_study(STUDY_ID)
142
+
143
+ lock_amendment(
144
+ STUDY_ID,
145
+ amendment_reason="Add chi-square",
146
+ post_hoc_tests=[{"variable_name": "pfs_event", "test_name": "chi_square", "rationale": "Post-hoc chi"}],
147
+ )
148
+
149
+ args = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
150
+ cmd_analyze(args)
151
+
152
+ conn = get_connection(STUDY_ID)
153
+ rows = conn.execute(
154
+ "SELECT test_name, is_pre_registered FROM analysis_results WHERE study_id=?",
155
+ (STUDY_ID,),
156
+ ).fetchall()
157
+ conn.close()
158
+
159
+ pre_reg = [r for r in rows if r["is_pre_registered"] == 1]
160
+ post_hoc = [r for r in rows if r["is_pre_registered"] == 0]
161
+ assert len(pre_reg) == 1, f"Expected 1 pre-registered result, got {len(pre_reg)}"
162
+ assert len(post_hoc) == 1, f"Expected 1 post-hoc result, got {len(post_hoc)}"
163
+ assert pre_reg[0]["test_name"] == "kaplan_meier_logrank"
164
+ assert post_hoc[0]["test_name"] == "chi_square"
165
+
166
+
167
+ def test_plot_km_works_with_posthoc_km_result():
168
+ """plot-km must succeed against a post-hoc kaplan_meier_logrank result."""
169
+ from core.reporting.plots import _resolve_km_vars
170
+
171
+ _set_state(1)
172
+ plan = StudyPlan(
173
+ study_id=STUDY_ID,
174
+ study_type="cohort",
175
+ primary_comparison="PFS by arm",
176
+ planned_tests=[],
177
+ )
178
+ lock_plan(STUDY_ID, plan)
179
+ _set_state(2)
180
+ unmask_study(STUDY_ID)
181
+
182
+ lock_amendment(
183
+ STUDY_ID,
184
+ amendment_reason="Add KM test",
185
+ post_hoc_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "Post-hoc KM"}],
186
+ )
187
+
188
+ args = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
189
+ cmd_analyze(args)
190
+
191
+ # Find the kaplan_meier_logrank result ID
192
+ conn = get_connection(STUDY_ID)
193
+ row = conn.execute(
194
+ "SELECT id FROM analysis_results WHERE study_id=? AND test_name='kaplan_meier_logrank' LIMIT 1",
195
+ (STUDY_ID,),
196
+ ).fetchone()
197
+ conn.close()
198
+ assert row is not None, "Expected a kaplan_meier_logrank result"
199
+
200
+ # _resolve_km_vars should not raise — it must find the test in post_hoc_tests
201
+ resolved = _resolve_km_vars(STUDY_ID, row["id"])
202
+ assert resolved["time_col"] == "pfs_days"
203
+ assert resolved["event_col"] == "pfs_event"
@@ -0,0 +1,110 @@
1
+ """Tests for provenance tracking."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from core.database import get_connection, init_db, DATA_ROOT
9
+ from core.provenance.tracker import ProvenanceTracker, ProvenanceEntry
10
+
11
+ STUDY_ID = "test_provenance"
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _setup():
16
+ conn = get_connection(STUDY_ID)
17
+ init_db(conn)
18
+ conn.execute(
19
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
20
+ (STUDY_ID, "Provenance Test", "2025-01-01T00:00:00",
21
+ str(Path("data/studies") / STUDY_ID)),
22
+ )
23
+ raw = f"raw_{STUDY_ID}"
24
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, response TEXT)")
25
+ conn.execute(f"INSERT INTO {raw} (age, response) VALUES ('65', 'CR')")
26
+ conn.execute(f"INSERT INTO {raw} (age, response) VALUES ('70', 'PR')")
27
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
28
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
29
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'response', 'outcome', 'categorical')", (STUDY_ID,))
30
+ conn.commit()
31
+ conn.close()
32
+ yield
33
+ p = DATA_ROOT / STUDY_ID
34
+ if p.exists():
35
+ shutil.rmtree(p)
36
+
37
+
38
+ def test_record_and_load():
39
+ tracker = ProvenanceTracker(STUDY_ID)
40
+ tracker.record_run(
41
+ function_name="chi_square",
42
+ parameters={"group_col": "treatment_arm", "outcome_col": "response"},
43
+ source_row_ids=[1, 2, 3, 4, 5],
44
+ column_names=["treatment_arm", "response"],
45
+ test_name="chi_square",
46
+ result_id="result_001",
47
+ is_pre_registered=True,
48
+ )
49
+ assert len(tracker.get_all()) == 1
50
+
51
+ # Re-load from disk
52
+ tracker2 = ProvenanceTracker(STUDY_ID)
53
+ assert len(tracker2.get_all()) == 1
54
+
55
+
56
+ def test_get_lineage():
57
+ tracker = ProvenanceTracker(STUDY_ID)
58
+ tracker.record_run(
59
+ function_name="chi_square",
60
+ parameters={},
61
+ source_row_ids=[1, 2, 3],
62
+ column_names=["x", "y"],
63
+ test_name="chi_square",
64
+ result_id="r1",
65
+ )
66
+ tracker.record_run(
67
+ function_name="t_test",
68
+ parameters={},
69
+ source_row_ids=[4, 5, 6],
70
+ column_names=["a", "b"],
71
+ test_name="t_test",
72
+ result_id="r2",
73
+ )
74
+ lines = tracker.get_lineage(test_name="chi_square")
75
+ assert len(lines) == 1
76
+ assert lines[0].test_name == "chi_square"
77
+
78
+
79
+ def test_provenance_entry_timestamp():
80
+ import datetime
81
+ e = ProvenanceEntry(
82
+ function_name="test",
83
+ parameters={},
84
+ source_row_ids=[1],
85
+ column_names=["x"],
86
+ test_name="test",
87
+ result_id="r1",
88
+ study_id=STUDY_ID,
89
+ )
90
+ assert e.computed_at is not None
91
+
92
+
93
+ def test_pre_registered_flag():
94
+ tracker = ProvenanceTracker(STUDY_ID)
95
+ tracker.record_run(
96
+ function_name="t_test",
97
+ parameters={},
98
+ source_row_ids=[],
99
+ column_names=[],
100
+ test_name="t_test",
101
+ result_id="r_post_hoc",
102
+ is_pre_registered=False,
103
+ )
104
+ entries = tracker.get_all()
105
+ assert entries[0].is_pre_registered is False
106
+
107
+
108
+ def test_empty_tracker():
109
+ tracker = ProvenanceTracker("nonexistent_study")
110
+ assert tracker.get_all() == []
@@ -0,0 +1,148 @@
1
+ """Regression tests for classification overrides, Cox checks, and appendix export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from core.cli.main import cmd_export, cmd_plan
13
+ from core.database import DATA_ROOT, get_connection, init_db
14
+ from core.planning.lock import lock_plan
15
+ from core.planning.study_plan import StudyPlan
16
+ from core.planning.test_selector import check_assumptions
17
+
18
+
19
+ STUDY_ID = "test_roadmap_features"
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def _setup():
24
+ study_path = DATA_ROOT / STUDY_ID
25
+ if study_path.exists():
26
+ shutil.rmtree(study_path)
27
+ study_path.mkdir(parents=True)
28
+ conn = get_connection(STUDY_ID)
29
+ init_db(conn)
30
+ conn.execute(
31
+ "INSERT INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
32
+ (STUDY_ID, "Roadmap", "2025-01-01", str(study_path), "cohort"),
33
+ )
34
+ conn.execute(
35
+ "CREATE TABLE raw_test_roadmap_features "
36
+ "(row_id INTEGER PRIMARY KEY, treatment_arm TEXT, response TEXT, "
37
+ "prior_lines TEXT, pfs_days TEXT, pfs_event TEXT)"
38
+ )
39
+ conn.executemany(
40
+ "INSERT INTO raw_test_roadmap_features "
41
+ "(treatment_arm, response, prior_lines, pfs_days, pfs_event) VALUES (?, ?, ?, ?, ?)",
42
+ [("A", "CR", "2", "10", "1"), ("A", "PR", "3", "20", "1"),
43
+ ("B", "SD", "1", "10", "0"), ("B", "PD", "2", "20", "0")],
44
+ )
45
+ conn.executemany(
46
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) VALUES (?, ?, ?, ?, ?)",
47
+ [(1, STUDY_ID, "response", "outcome", "categorical"),
48
+ (2, STUDY_ID, "prior_lines", "baseline", "continuous"),
49
+ (3, STUDY_ID, "pfs_days", "outcome", "time_to_event"),
50
+ (4, STUDY_ID, "treatment_arm", "baseline", "categorical")],
51
+ )
52
+ conn.commit()
53
+ conn.close()
54
+ yield
55
+ if study_path.exists():
56
+ shutil.rmtree(study_path)
57
+
58
+
59
+ def _plan_args(**kwargs):
60
+ values = dict(
61
+ study_id=STUDY_ID,
62
+ outcome_var_ids="1",
63
+ tests=["response:chi_square:"],
64
+ covariates="",
65
+ study_type="cohort",
66
+ comparison="response by treatment arm",
67
+ overrides=[],
68
+ )
69
+ values.update(kwargs)
70
+ return argparse.Namespace(**values)
71
+
72
+
73
+ def test_plan_persists_valid_role_override():
74
+ cmd_plan(_plan_args(overrides=["id=2:role=covariate"]))
75
+
76
+ plan = json.loads((DATA_ROOT / STUDY_ID / "study_plan.provisional.json").read_text())
77
+ assert plan["role_overrides"] == {"2": "covariate"}
78
+ assert plan["audit"]["role_overrides"] == [{"variable_id": 2, "role": "covariate"}]
79
+
80
+
81
+ @pytest.mark.parametrize("override", ["id=99:role=covariate", "id=2:role=banana", "2:role=covariate"])
82
+ def test_plan_rejects_invalid_override(override, capsys):
83
+ with pytest.raises(SystemExit):
84
+ cmd_plan(_plan_args(overrides=[override]))
85
+ assert "override" in capsys.readouterr().err.lower()
86
+
87
+
88
+ def test_plan_rejects_override_after_lock(capsys):
89
+ lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort"))
90
+
91
+ with pytest.raises(SystemExit):
92
+ cmd_plan(_plan_args(overrides=["id=2:role=covariate"]))
93
+ assert "locked" in capsys.readouterr().err.lower()
94
+
95
+
96
+ def test_cox_assumption_check_warns_on_marginal_event_rate_difference():
97
+ conn = get_connection(STUDY_ID)
98
+ masked = f"raw_masked_{STUDY_ID}"
99
+ conn.execute(f"CREATE TABLE {masked} (row_id INTEGER PRIMARY KEY, pfs_days TEXT, pfs_event TEXT)")
100
+ conn.execute(
101
+ f"INSERT INTO {masked} SELECT row_id, pfs_days, pfs_event FROM raw_{STUDY_ID}"
102
+ )
103
+ conn.commit()
104
+ conn.close()
105
+
106
+ warnings = check_assumptions(
107
+ STUDY_ID,
108
+ [{"variable_name": "pfs_days", "test_name": "cox_proportional_hazards",
109
+ "n_covariates": 3}],
110
+ )
111
+ # 4 rows in test data, 2 events → EPV = 2/4 = 0.5 → warn
112
+ assert any("EPV" in warning for warning in warnings)
113
+ assert all("A" not in warning.split(":")[-1] for warning in warnings)
114
+
115
+
116
+ def test_plan_persists_cox_warning_for_analysis_enforcement():
117
+ conn = get_connection(STUDY_ID)
118
+ masked = f"raw_masked_{STUDY_ID}"
119
+ conn.execute(f"CREATE TABLE {masked} (row_id INTEGER PRIMARY KEY, pfs_days TEXT, pfs_event TEXT)")
120
+ conn.execute(f"INSERT INTO {masked} SELECT row_id, pfs_days, pfs_event FROM raw_{STUDY_ID}")
121
+ conn.commit()
122
+ conn.close()
123
+
124
+ cmd_plan(_plan_args(
125
+ outcome_var_ids="3",
126
+ tests=["pfs_days:cox_proportional_hazards:adjusted survival"],
127
+ ))
128
+ plan = json.loads((DATA_ROOT / STUDY_ID / "study_plan.provisional.json").read_text())
129
+ assert "pfs_days" in plan["warnings"]
130
+
131
+
132
+ def test_appendix_export_contains_table1_uro_warnings_and_strobe():
133
+ lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort"))
134
+ args = argparse.Namespace(
135
+ study_id=STUDY_ID,
136
+ mode="supplementary",
137
+ study_plan_version=None,
138
+ format="appendix",
139
+ )
140
+ cmd_export(args)
141
+
142
+ appendix = DATA_ROOT / STUDY_ID / "study_result.v1.appendix.md"
143
+ assert appendix.exists()
144
+ text = appendix.read_text()
145
+ assert "Table 1" in text
146
+ assert "URO" in text
147
+ assert "STROBE" in text
148
+ assert "Warnings" in text
@@ -0,0 +1,57 @@
1
+ """Tests for descriptive statistics (Table 1)."""
2
+
3
+ from __future__ import annotations
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from core.database import get_connection, init_db, DATA_ROOT
10
+ from core.stats.descriptive import generate_table1
11
+
12
+ STUDY_ID = "test_descriptive"
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _setup():
17
+ conn = get_connection(STUDY_ID)
18
+ init_db(conn)
19
+ conn.execute(
20
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
21
+ (STUDY_ID, "Descriptive Test", "2025-01-01T00:00:00",
22
+ str(Path("data/studies") / STUDY_ID)),
23
+ )
24
+ raw = f"raw_{STUDY_ID}"
25
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, sex TEXT, iss_stage TEXT)")
26
+ for i in range(10):
27
+ conn.execute(f"INSERT INTO {raw} (age, sex, iss_stage) VALUES (?, ?, ?)",
28
+ (str(50 + i * 3), ["M", "F"][i % 2], ["I", "II", "III"][i % 3]))
29
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
30
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
31
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'sex', 'baseline', 'categorical')", (STUDY_ID,))
32
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'iss_stage', 'baseline', 'categorical')", (STUDY_ID,))
33
+ conn.commit()
34
+ conn.close()
35
+ yield
36
+ p = DATA_ROOT / STUDY_ID
37
+ if p.exists():
38
+ shutil.rmtree(p)
39
+
40
+
41
+ def test_table1_returns_dataframe():
42
+ tbl = generate_table1(STUDY_ID)
43
+ assert tbl is not None
44
+ # Should have rows for each variable
45
+ assert len(tbl) > 0
46
+
47
+
48
+ def test_table1_includes_age():
49
+ tbl = generate_table1(STUDY_ID)
50
+ # tableone labels rows with variable names
51
+ age_rows = tbl.filter(like="age", axis=0)
52
+ assert len(age_rows) > 0
53
+
54
+
55
+ def test_table1_grouped():
56
+ tbl = generate_table1(STUDY_ID, groupby="sex")
57
+ assert tbl is not None