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